Amazon SQS #
When your application is already running on AWS infrastructure, choosing a message broker fully managed by the cloud provider is a very sensible decision. Amazon Simple Queue Service (SQS) is a managed queue service that requires no server provisioning, no cluster to maintain, and scales automatically from one message to billions of messages per day. Unlike RabbitMQ, which requires you to understand exchanges and bindings, or Kafka, which requires managing partitions and consumer groups, SQS is designed to be as simple as possible: send a message to a queue, retrieve a message from a queue, delete the message after processing. This simplicity is both its strength and its limitation — and understanding that tradeoff is the key to using SQS effectively.
SQS Basics #
SQS uses a model different from traditional brokers. There are several unique concepts you won’t find in RabbitMQ or Kafka.
Visibility Timeout #
This is the most important concept in SQS and the most commonly misunderstood. When a consumer retrieves (receives) a message from SQS, the message is not immediately deleted — it becomes invisible to other consumers for the duration of the visibility timeout. The consumer has the visibility timeout period to process and delete the message. If the message isn’t deleted within that time, SQS assumes processing failed and makes the message visible again.
sequenceDiagram
participant C as Consumer
participant SQS
C->>SQS: ReceiveMessage
SQS-->>C: message (visibility timeout starts: 30s)
Note over SQS: message invisible\nto other consumers
alt Processing succeeds
C->>SQS: DeleteMessage
Note over SQS: message permanently deleted
else Timeout expires (consumer crash)
Note over SQS: message becomes visible again
SQS-->>C: message redelivered to another consumer
endStandard Queue vs FIFO Queue #
SQS has two queue types with very different characteristics:
| Standard Queue | FIFO Queue | |
|---|---|---|
| Throughput | Nearly unlimited | 300 msg/s (3,000 with batching) |
| Ordering | Best-effort (not guaranteed) | Strict — first-in, first-out |
| Duplication | Possible (at-least-once) | Exactly once |
| Queue name | Free-form | Must end with .fifo |
| Price | Cheaper | More expensive |
| Use case | High throughput, duplicate-tolerant | Financial transactions, critical ordering |
flowchart TD
A{"Message ordering\nmust be guaranteed?"} -- Yes --> B{"Throughput\n> 300 msg/s?"}
A -- No --> C{"Can duplicate messages\nbe tolerated?"}
B -- Yes --> D["Consider Kafka\nor a rearchitecture"]
B -- No --> E[FIFO Queue]
C -- Yes --> F["Standard Queue\ncheaper and faster"]
C -- No --> G{"Need a 5-minute\ndeduplication window?"}
G -- Yes --> E
G -- No --> FMessage Groups (FIFO Queues) #
FIFO queues support the message group concept — each group is processed sequentially, but different groups can be processed in parallel. This provides scalability while still guaranteeing per-entity ordering.
FIFO Queue — order-processing.fifo
MessageGroupId = "order-123" → [event-1] [event-2] [event-3] ← ordered
MessageGroupId = "order-456" → [event-1] [event-2] ← ordered, parallel with order-123
MessageGroupId = "order-789" → [event-1] ← ordered, parallel with both groups
Setting Up Dependencies #
Add the AWS SDK v2 for SQS to your project. AWS SDK v2 is the modern version that uses a builder pattern and natively supports async.
For Maven:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.25.60</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- SQS client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sqs</artifactId>
</dependency>
<!-- URL Connection HTTP client (lightweight, no Netty needed) -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>url-connection-client</artifactId>
</dependency>
</dependencies>
For Gradle:
dependencies {
implementation platform('software.amazon.awssdk:bom:2.25.60')
implementation 'software.amazon.awssdk:sqs'
implementation 'software.amazon.awssdk:url-connection-client'
}
For local development without an AWS account, use LocalStack:
# Run LocalStack — a local AWS services emulator
docker run -d \
--name localstack \
-p 4566:4566 \
-e SERVICES=sqs \
localstack/localstack
# Create a queue via the AWS CLI (pointing to LocalStack)
aws --endpoint-url=http://localhost:4566 sqs create-queue \
--queue-name order-queue \
--region ap-southeast-1
Creating the SQS Client #
Client configuration differs between production environments (AWS) and development (LocalStack):
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sqs.SqsClient;
import java.net.URI;
public class SqsClientFactory {
// ✓ CORRECT: production — use DefaultCredentialsProvider
// Automatically finds credentials from: env vars → ~/.aws/credentials → IAM role
public static SqsClient createProductionClient() {
return SqsClient.builder()
.region(Region.AP_SOUTHEAST_1)
.credentialsProvider(DefaultCredentialsProvider.create())
.httpClient(UrlConnectionHttpClient.builder().build())
.build();
}
// ✓ CORRECT: development with LocalStack
public static SqsClient createLocalStackClient() {
return SqsClient.builder()
.region(Region.AP_SOUTHEAST_1)
.endpointOverride(URI.create("http://localhost:4566"))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test") // LocalStack doesn't validate credentials
))
.httpClient(UrlConnectionHttpClient.builder().build())
.build();
}
// ✗ ANTI-PATTERN: hardcoding credentials in code
// Credentials can leak into version control
public static SqsClient createInsecureClient() {
return SqsClient.builder()
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("«redacted:AKIA…»", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
))
.build();
}
}
Never hardcode AWS credentials (Access Key ID and Secret Access Key) in source code. UseDefaultCredentialsProviderin production, which automatically reads from theAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYenvironment variables, the~/.aws/credentialsfile, or an IAM role when running on EC2/ECS/Lambda.
Queue Management #
Before sending or receiving messages, the queue must exist. You can create and manage queues programmatically.
import software.amazon.awssdk.services.sqs.model.*;
import java.util.Map;
public class SqsQueueManager {
private final SqsClient sqsClient;
public SqsQueueManager(SqsClient sqsClient) {
this.sqsClient = sqsClient;
}
// Create a Standard Queue
public String createStandardQueue(String queueName) {
CreateQueueRequest request = CreateQueueRequest.builder()
.queueName(queueName)
.attributes(Map.of(
QueueAttributeName.VISIBILITY_TIMEOUT, "30", // seconds
QueueAttributeName.MESSAGE_RETENTION_PERIOD, "86400", // 1 day (seconds)
QueueAttributeName.RECEIVE_MESSAGE_WAIT_TIME_SECONDS, "20", // long polling
QueueAttributeName.MAX_MESSAGE_SIZE, "262144" // 256 KB (SQS max)
))
.build();
CreateQueueResponse response = sqsClient.createQueue(request);
System.out.println("Queue created: " + response.queueUrl());
return response.queueUrl();
}
// Create a FIFO Queue
public String createFifoQueue(String queueName) {
// FIFO queue names must end with .fifo
if (!queueName.endsWith(".fifo")) {
queueName = queueName + ".fifo";
}
CreateQueueRequest request = CreateQueueRequest.builder()
.queueName(queueName)
.attributes(Map.of(
QueueAttributeName.FIFO_QUEUE, "true",
QueueAttributeName.CONTENT_BASED_DEDUPLICATION, "false", // use explicit deduplication IDs
QueueAttributeName.VISIBILITY_TIMEOUT, "30",
QueueAttributeName.MESSAGE_RETENTION_PERIOD, "86400"
))
.build();
CreateQueueResponse response = sqsClient.createQueue(request);
return response.queueUrl();
}
// Create a Standard Queue with a Dead Letter Queue
public String createQueueWithDLQ(String mainQueueName, String dlqName) {
// 1. Create the DLQ first
String dlqUrl = createStandardQueue(dlqName);
// 2. Get the ARN of the DLQ
GetQueueAttributesResponse dlqAttrs = sqsClient.getQueueAttributes(
GetQueueAttributesRequest.builder()
.queueUrl(dlqUrl)
.attributeNames(QueueAttributeName.QUEUE_ARN)
.build()
);
String dlqArn = dlqAttrs.attributes().get(QueueAttributeName.QUEUE_ARN);
// 3. Create the main queue with a redrive policy pointing to the DLQ
String redrivePolicy = String.format(
"{\"deadLetterTargetArn\":\"%s\",\"maxReceiveCount\":\"3\"}",
dlqArn
);
// maxReceiveCount=3 → messages go to the DLQ after failing to process 3 times
CreateQueueRequest request = CreateQueueRequest.builder()
.queueName(mainQueueName)
.attributes(Map.of(
QueueAttributeName.VISIBILITY_TIMEOUT, "30",
QueueAttributeName.RECEIVE_MESSAGE_WAIT_TIME_SECONDS, "20",
QueueAttributeName.REDRIVE_POLICY, redrivePolicy
))
.build();
CreateQueueResponse response = sqsClient.createQueue(request);
System.out.println("Main queue: " + response.queueUrl());
System.out.println("DLQ: " + dlqUrl);
return response.queueUrl();
}
// Get a queue URL by name
public String getQueueUrl(String queueName) {
return sqsClient.getQueueUrl(
GetQueueUrlRequest.builder().queueName(queueName).build()
).queueUrl();
}
}
Sending Messages (Producer) #
Sending a Single Message #
import software.amazon.awssdk.services.sqs.model.MessageAttributeValue;
import software.amazon.awssdk.services.sqs.model.SendMessageRequest;
import software.amazon.awssdk.services.sqs.model.SendMessageResponse;
public class SqsProducer {
private final SqsClient sqsClient;
private final String queueUrl;
public SqsProducer(SqsClient sqsClient, String queueUrl) {
this.sqsClient = sqsClient;
this.queueUrl = queueUrl;
}
// Send a simple message to a Standard Queue
public String send(String messageBody) {
SendMessageRequest request = SendMessageRequest.builder()
.queueUrl(queueUrl)
.messageBody(messageBody)
.delaySeconds(0) // delivery delay (0-900 seconds), default 0
.messageAttributes(Map.of(
"source-service", MessageAttributeValue.builder()
.dataType("String")
.stringValue("order-service")
.build(),
"event-type", MessageAttributeValue.builder()
.dataType("String")
.stringValue("order.created")
.build(),
"retry-count", MessageAttributeValue.builder()
.dataType("Number")
.stringValue("0")
.build()
))
.build();
SendMessageResponse response = sqsClient.sendMessage(request);
System.out.println("Message sent — MessageId: " + response.messageId());
return response.messageId();
}
// Send to a FIFO Queue — requires MessageGroupId and MessageDeduplicationId
public String sendToFifo(String messageBody, String messageGroupId, String deduplicationId) {
SendMessageRequest request = SendMessageRequest.builder()
.queueUrl(queueUrl)
.messageBody(messageBody)
// MessageGroupId — all messages in the same group are processed in order
.messageGroupId(messageGroupId)
// MessageDeduplicationId — SQS rejects duplicate messages within a 5-minute window
// Use a value unique per message (e.g. UUID or a content hash)
.messageDeduplicationId(deduplicationId)
.build();
SendMessageResponse response = sqsClient.sendMessage(request);
return response.messageId();
}
}
Batch Sends — Saving Money #
SQS bills per API request, not per message. Sending 10 messages in one batch request costs the same as sending 1 message. Always use batches when possible.
import software.amazon.awssdk.services.sqs.model.SendMessageBatchRequest;
import software.amazon.awssdk.services.sqs.model.SendMessageBatchRequestEntry;
import software.amazon.awssdk.services.sqs.model.SendMessageBatchResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class SqsBatchProducer {
private final SqsClient sqsClient;
private final String queueUrl;
public SqsBatchProducer(SqsClient sqsClient, String queueUrl) {
this.sqsClient = sqsClient;
this.queueUrl = queueUrl;
}
// ✗ ANTI-PATTERN: sending one by one in a loop
// 100 messages = 100 API calls = 100x the cost
public void sendOneByOne(List<String> messages) {
for (String msg : messages) {
sqsClient.sendMessage(SendMessageRequest.builder()
.queueUrl(queueUrl)
.messageBody(msg)
.build());
}
}
// ✓ CORRECT: batch send — max 10 messages per batch request
// 100 messages = 10 API calls = 10x cheaper
public void sendBatch(List<String> messages) {
// SQS limits batches to 10 messages
int batchSize = 10;
for (int i = 0; i < messages.size(); i += batchSize) {
List<String> batch = messages.subList(i, Math.min(i + batchSize, messages.size()));
sendSingleBatch(batch);
}
}
private void sendSingleBatch(List<String> batch) {
List<SendMessageBatchRequestEntry> entries = new ArrayList<>();
for (int i = 0; i < batch.size(); i++) {
entries.add(SendMessageBatchRequestEntry.builder()
.id(String.valueOf(i)) // unique ID within the batch (not a global message ID)
.messageBody(batch.get(i))
.build());
}
SendMessageBatchResponse response = sqsClient.sendMessageBatch(
SendMessageBatchRequest.builder()
.queueUrl(queueUrl)
.entries(entries)
.build()
);
// Check for messages that failed to send within the batch
if (!response.failed().isEmpty()) {
response.failed().forEach(failure -> {
System.err.printf(
"Failed to send message ID=%s: %s — %s%n",
failure.id(), failure.code(), failure.message()
);
// Here: retry the failed messages or send to a fallback
});
}
System.out.printf("Batch sent: %d succeeded, %d failed%n",
response.successful().size(), response.failed().size());
}
}
Receiving and Processing Messages (Consumer) #
Long Polling #
SQS supports two polling modes: short polling and long polling. Always use long polling.
SHORT POLLING (WaitTimeSeconds=0):
Consumer → SQS: "Any messages?"
SQS → Consumer: "None" (even though messages may exist)
[repeat every second]
Result: many empty responses, high cost, unpredictable latency
LONG POLLING (WaitTimeSeconds=1-20):
Consumer → SQS: "Any messages? Wait up to 20 seconds"
SQS: [waits until a message arrives or times out]
SQS → Consumer: message (as soon as one is available)
Result: fewer empty responses, lower cost, lower latency
import software.amazon.awssdk.services.sqs.model.DeleteMessageRequest;
import software.amazon.awssdk.services.sqs.model.Message;
import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest;
import software.amazon.awssdk.services.sqs.model.ReceiveMessageResponse;
public class SqsConsumer {
private final SqsClient sqsClient;
private final String queueUrl;
private volatile boolean running = true;
public SqsConsumer(SqsClient sqsClient, String queueUrl) {
this.sqsClient = sqsClient;
this.queueUrl = queueUrl;
}
public void start() {
System.out.println("Consumer started...");
while (running) {
try {
// ✓ Long polling — wait up to 20 seconds if there are no messages
ReceiveMessageRequest receiveRequest = ReceiveMessageRequest.builder()
.queueUrl(queueUrl)
.maxNumberOfMessages(10) // max 10 messages per receive (SQS max)
.waitTimeSeconds(20) // long polling
.visibilityTimeout(30) // the consumer has 30 seconds to process
.messageAttributeNames("All") // fetch all message attributes
.attributeNames( // fetch queue attributes (ApproximateReceiveCount, etc.)
software.amazon.awssdk.services.sqs.model.MessageSystemAttributeName.APPROXIMATE_RECEIVE_COUNT,
software.amazon.awssdk.services.sqs.model.MessageSystemAttributeName.SENT_TIMESTAMP
)
.build();
ReceiveMessageResponse response = sqsClient.receiveMessage(receiveRequest);
List<Message> messages = response.messages();
if (messages.isEmpty()) {
continue; // no messages, poll again
}
for (Message message : messages) {
processAndDelete(message);
}
} catch (Exception e) {
System.err.println("Error while polling: " + e.getMessage());
// don't crash — wait a moment then try again
sleep(5000);
}
}
}
private void processAndDelete(Message message) {
try {
System.out.printf("Processing MessageId=%s: %s%n",
message.messageId(), message.body());
// Check how many times this message has been received
String receiveCount = message.attributes().get(
software.amazon.awssdk.services.sqs.model.MessageSystemAttributeName.APPROXIMATE_RECEIVE_COUNT
);
System.out.println("Received " + receiveCount + " times");
doProcess(message.body());
// ✓ Delete the message after successful processing
// Use the ReceiptHandle (not MessageId) for deletion
sqsClient.deleteMessage(DeleteMessageRequest.builder()
.queueUrl(queueUrl)
.receiptHandle(message.receiptHandle())
.build());
System.out.println("Message processed and deleted successfully.");
} catch (Exception e) {
System.err.printf("Failed to process MessageId=%s: %s%n",
message.messageId(), e.getMessage());
// Don't delete the message — let the visibility timeout expire
// SQS will redeliver this message to another consumer
// After maxReceiveCount is reached, it automatically goes to the DLQ
}
}
private void doProcess(String body) throws Exception {
// business logic
}
public void stop() {
running = false;
}
private void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
Extending the Visibility Timeout #
If you know processing will take longer than the visibility timeout, extend it before it expires:
import software.amazon.awssdk.services.sqs.model.ChangeMessageVisibilityRequest;
public class LongRunningProcessor {
private final SqsClient sqsClient;
private final String queueUrl;
public LongRunningProcessor(SqsClient sqsClient, String queueUrl) {
this.sqsClient = sqsClient;
this.queueUrl = queueUrl;
}
public void processHeavyTask(Message message) throws Exception {
// Run a timer to extend the visibility timeout every 25 seconds
// (initial visibility timeout = 30 seconds, extend 5 seconds before it expires)
java.util.concurrent.ScheduledExecutorService scheduler =
java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
try {
sqsClient.changeMessageVisibility(
ChangeMessageVisibilityRequest.builder()
.queueUrl(queueUrl)
.receiptHandle(message.receiptHandle())
.visibilityTimeout(30) // extend by another 30 seconds
.build()
);
System.out.println("Visibility timeout extended.");
} catch (Exception e) {
System.err.println("Failed to extend visibility timeout: " + e.getMessage());
}
}, 25, 25, java.util.concurrent.TimeUnit.SECONDS);
try {
// Do the heavy processing that takes a long time
doHeavyWork(message.body());
// Delete the message when finished
sqsClient.deleteMessage(DeleteMessageRequest.builder()
.queueUrl(queueUrl)
.receiptHandle(message.receiptHandle())
.build());
} finally {
scheduler.shutdown();
}
}
private void doHeavyWork(String body) throws Exception {
// simulate heavy processing — e.g. video encoding, large report generation
Thread.sleep(60_000); // 1 minute
}
}
Batch Deletes — Saving Money on Deletion #
Just like batch sends, deletes can also be batched:
import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchRequest;
import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchRequestEntry;
public class SqsBatchConsumer {
private final SqsClient sqsClient;
private final String queueUrl;
public SqsBatchConsumer(SqsClient sqsClient, String queueUrl) {
this.sqsClient = sqsClient;
this.queueUrl = queueUrl;
}
public void processAndDeleteBatch() {
ReceiveMessageRequest receiveRequest = ReceiveMessageRequest.builder()
.queueUrl(queueUrl)
.maxNumberOfMessages(10)
.waitTimeSeconds(20)
.build();
List<Message> messages = sqsClient.receiveMessage(receiveRequest).messages();
if (messages.isEmpty()) return;
List<DeleteMessageBatchRequestEntry> toDelete = new ArrayList<>();
List<String> failed = new ArrayList<>();
for (Message message : messages) {
try {
doProcess(message.body());
// Collect the receipt handles for batch deletion
toDelete.add(DeleteMessageBatchRequestEntry.builder()
.id(message.messageId())
.receiptHandle(message.receiptHandle())
.build());
} catch (Exception e) {
failed.add(message.messageId());
System.err.println("Failed to process: " + message.messageId());
}
}
// ✓ Batch delete all successfully processed messages in one API call
if (!toDelete.isEmpty()) {
var deleteResponse = sqsClient.deleteMessageBatch(
DeleteMessageBatchRequest.builder()
.queueUrl(queueUrl)
.entries(toDelete)
.build()
);
if (!deleteResponse.failed().isEmpty()) {
deleteResponse.failed().forEach(f ->
System.err.println("Failed to delete MessageId=" + f.id() + ": " + f.message())
);
}
}
System.out.printf("Batch complete: %d processed, %d failed%n",
toDelete.size(), failed.size());
}
private void doProcess(String body) throws Exception {
// business logic
}
}
Dead Letter Queues #
SQS has a built-in DLQ mechanism via the Redrive Policy. When a message has been received more than maxReceiveCount times without being successfully deleted, SQS automatically moves it to the DLQ.
flowchart TD
P[Producer] --> Q["order-queue\nmaxReceiveCount=3"]
Q --> C{"Consumer\nprocessed successfully?"}
C -- Yes\nDeleteMessage --> DONE[Message deleted]
C -- No\nTimeout / Error --> Q
Q -->|Received 3 times| DLQ[dlq-order-queue]
DLQ --> MON["DLQ Monitor\nAlert & Investigation"]
MON -->|After the fix| REDRIVE["Redrive to the\nmain queue"]Monitoring and reprocessing messages from the DLQ:
public class DlqMonitor {
private final SqsClient sqsClient;
private final String dlqUrl;
private final String mainQueueUrl;
public DlqMonitor(SqsClient sqsClient, String dlqUrl, String mainQueueUrl) {
this.sqsClient = sqsClient;
this.dlqUrl = dlqUrl;
this.mainQueueUrl = mainQueueUrl;
}
// Check the number of messages in the DLQ
public int getDlqMessageCount() {
GetQueueAttributesResponse response = sqsClient.getQueueAttributes(
GetQueueAttributesRequest.builder()
.queueUrl(dlqUrl)
.attributeNames(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES)
.build()
);
return Integer.parseInt(
response.attributes().get(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES)
);
}
// Read messages from the DLQ for investigation
public void inspectDlq() {
ReceiveMessageRequest request = ReceiveMessageRequest.builder()
.queueUrl(dlqUrl)
.maxNumberOfMessages(10)
.waitTimeSeconds(1) // short poll for manual inspection
.attributeNames(
software.amazon.awssdk.services.sqs.model.MessageSystemAttributeName.APPROXIMATE_RECEIVE_COUNT,
software.amazon.awssdk.services.sqs.model.MessageSystemAttributeName.SENT_TIMESTAMP
)
.build();
List<Message> messages = sqsClient.receiveMessage(request).messages();
System.out.println("Messages in DLQ: " + messages.size());
for (Message msg : messages) {
System.out.printf(
"MessageId=%s | Received %s times | Body: %s%n",
msg.messageId(),
msg.attributes().get(
software.amazon.awssdk.services.sqs.model.MessageSystemAttributeName.APPROXIMATE_RECEIVE_COUNT
),
msg.body()
);
}
}
// Redrive — move messages from the DLQ back to the main queue after a bug is fixed
// The AWS Console provides a built-in "Start DLQ redrive" feature,
// but this is a manual implementation if you need more control:
public void redriveMessages() {
List<Message> messages;
int redrived = 0;
do {
messages = sqsClient.receiveMessage(
ReceiveMessageRequest.builder()
.queueUrl(dlqUrl)
.maxNumberOfMessages(10)
.waitTimeSeconds(1)
.build()
).messages();
for (Message msg : messages) {
// Resend to the main queue
sqsClient.sendMessage(SendMessageRequest.builder()
.queueUrl(mainQueueUrl)
.messageBody(msg.body())
.build());
// Delete from the DLQ
sqsClient.deleteMessage(DeleteMessageRequest.builder()
.queueUrl(dlqUrl)
.receiptHandle(msg.receiptHandle())
.build());
redrived++;
}
} while (!messages.isEmpty());
System.out.println("Total messages redrived: " + redrived);
}
}
SNS + SQS Integration — The Fan-out Pattern #
SQS by itself doesn’t support fan-out (one message to many consumers). For that, combine it with Amazon SNS (Simple Notification Service). SNS acts as the publisher that distributes messages to all subscribed SQS queues.
flowchart TD
P[Producer] --> SNS["SNS Topic\norder-events"]
SNS --> Q1["SQS Queue\nanalytics-queue"]
SNS --> Q2["SQS Queue\nnotification-queue"]
SNS --> Q3["SQS Queue\naudit-queue"]
Q1 --> C1[Analytics Service]
Q2 --> C2[Notification Service]
Q3 --> C3[Audit Service]import software.amazon.awssdk.services.sns.SnsClient;
import software.amazon.awssdk.services.sns.model.*;
public class SnsSqsFanout {
private final SnsClient snsClient;
private final SqsClient sqsClient;
public SnsSqsFanout(SnsClient snsClient, SqsClient sqsClient) {
this.snsClient = snsClient;
this.sqsClient = sqsClient;
}
public void setupFanout(String topicName, List<String> queueArns) {
// 1. Create the SNS Topic
CreateTopicResponse topicResponse = snsClient.createTopic(
CreateTopicRequest.builder().name(topicName).build()
);
String topicArn = topicResponse.topicArn();
// 2. Subscribe each SQS queue to the SNS topic
for (String queueArn : queueArns) {
snsClient.subscribe(SubscribeRequest.builder()
.topicArn(topicArn)
.protocol("sqs")
.endpoint(queueArn)
.build());
}
System.out.println("Fan-out setup complete. Topic ARN: " + topicArn);
}
// Publish to SNS — automatically delivered to all subscribing SQS queues
public void publish(String topicArn, String message) {
snsClient.publish(PublishRequest.builder()
.topicArn(topicArn)
.message(message)
.subject("order-event")
.build());
}
}
When SQS receives a message from SNS, the message body is wrapped in an SNS JSON envelope. SQS consumers need to parse that envelope to get the original message. Use the Message attribute inside the SNS JSON notification, not the SQS message body directly.Handling Duplicate Messages #
Standard Queues can deliver a message more than once (at-least-once delivery). Consumers must be idempotent — processing the same message twice must not produce different effects.
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class IdempotentConsumer {
// In production: use Redis or DynamoDB to store processed IDs
// with a TTL matching the queue retention period
private final Set<String> processedIds = ConcurrentHashMap.newKeySet();
public void processIdempotent(Message message) throws Exception {
String messageId = message.messageId();
// ✗ ANTI-PATTERN: process directly without checking for duplicates
// doProcess(message.body()); // could execute twice!
// ✓ CORRECT: check whether it's already been processed
if (processedIds.contains(messageId)) {
System.out.println("Duplicate ignored: " + messageId);
return; // return immediately, but still delete later
}
// Process the message
doProcess(message.body());
// Mark it as processed
processedIds.add(messageId);
}
private void doProcess(String body) throws Exception {
System.out.println("Processing: " + body);
// business logic that produces effects (write to DB, send email, etc.)
}
}
For production, use DynamoDB as the idempotency store:
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.util.Map;
public class DynamoDbIdempotencyStore {
private final DynamoDbClient dynamoDb;
private static final String TABLE = "sqs-processed-messages";
public DynamoDbIdempotencyStore(DynamoDbClient dynamoDb) {
this.dynamoDb = dynamoDb;
}
// Try to mark a message as "processing"
// Uses a conditional write — only succeeds if the messageId doesn't exist
public boolean tryMarkAsProcessing(String messageId) {
try {
dynamoDb.putItem(PutItemRequest.builder()
.tableName(TABLE)
.item(Map.of(
"messageId", AttributeValue.fromS(messageId),
"status", AttributeValue.fromS("PROCESSING"),
"ttl", AttributeValue.fromN( // auto-delete after 24 hours
String.valueOf(System.currentTimeMillis() / 1000 + 86400)
)
))
.conditionExpression("attribute_not_exists(messageId)")
.build());
return true; // success — this message has never been processed
} catch (software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException e) {
return false; // already processed — this is a duplicate
}
}
public void markAsCompleted(String messageId) {
dynamoDb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("messageId", AttributeValue.fromS(messageId)))
.updateExpression("SET #s = :completed")
.expressionAttributeNames(Map.of("#s", "status"))
.expressionAttributeValues(Map.of(":completed", AttributeValue.fromS("COMPLETED")))
.build());
}
}
When to Use SQS and When Not To #
USE SQS WHEN:
✓ You're already in the AWS ecosystem and want a fully managed service
✓ You don't want the hassle of maintaining a broker — SQS is zero-ops
✓ Simple task queues without complex routing
✓ Highly variable volume — SQS auto-scales without configuration
✓ You need easy integration with Lambda, SNS, S3, and other AWS services
✓ Costs need to be controlled — pay per request, no idle cost
✓ You need FIFO with easily configurable exactly-once delivery
CONSIDER ALTERNATIVES WHEN:
✗ You need complex routing (exchange patterns) → RabbitMQ is more flexible
✗ Very high volume (millions of msg/s) with ultra-low latency → Kafka
✗ You need to replay processed messages → Kafka with log retention
✗ You're not on AWS and don't want vendor lock-in → RabbitMQ or Kafka
✗ You need streaming and real-time aggregation → Kafka Streams or Kinesis
✗ Messages larger than 256 KB → SQS doesn't support it, use S3 + SQS pointer
Summary #
- The visibility timeout is SQS’s core concept — messages aren’t deleted when received, only made invisible. Delete messages explicitly with
DeleteMessageafter successful processing.- Standard Queues for high throughput with duplicate tolerance; FIFO Queues for strict ordering and exactly-once delivery — more expensive and more limited throughput.
- Always use long polling (
WaitTimeSeconds=20) to reduce empty responses, save API costs, and lower latency compared to short polling.- Batch sends and batch deletes save up to 10x the cost because SQS bills per API request, not per message. Maximum 10 messages per batch.
- Dead Letter Queues are configured via a Redrive Policy at the queue level — messages automatically enter the DLQ after failing to process
maxReceiveCounttimes.- Extend the visibility timeout (
ChangeMessageVisibility) if processing takes longer than the initial timeout — this prevents messages from being redelivered to other consumers while still being processed.- Standard Queues are at-least-once — consumers must be idempotent. Use DynamoDB with conditional writes as the idempotency store in production.
- SNS + SQS for the fan-out pattern — SNS distributes one message to many SQS queues in parallel, providing a capability SQS alone doesn’t have.
- SQS fits best for the AWS ecosystem, zero-ops task queues, and highly variable volume — not for complex routing, replay, or real-time streaming.