How SQS FIFO queues guarantee ordering and exactly-once delivery in Java Spring Boot — MessageGroupId, deduplication, throughput limits, and DLQ configuration.
Standard SQS queues are fast and cheap, but they give you at-least-once delivery in no particular order. For many workloads that’s fine. When you need ordered processing within a logical group — or protection against duplicate delivery — FIFO queues are the right tool.
This post covers the mechanics of SQS FIFO in Java with Spring Boot: how message group ordering works, how deduplication is enforced, what the throughput constraints mean in practice, and how to configure a dead-letter queue correctly.
MessageGroupId are delivered in the order they were sent. Different groups are processed independently and in parallel.MessageDeduplicationId sent within a 5-minute window are silently discarded by SQS.The tradeoff: FIFO queues support 300 API calls per second (send, receive, delete) per queue, or 3,000 with batching and high-throughput mode.
@Configuration
public class SqsFifoConfig {
@Bean
public String tradeEventQueueUrl(SqsAsyncClient sqsClient,
@Value("${trade.dlq.arn}") String dlqArn) {
var response = sqsClient.createQueue(CreateQueueRequest.builder()
.queueName("trade-events.fifo")
.attributes(Map.of(
QueueAttributeName.FIFO_QUEUE, "true",
QueueAttributeName.CONTENT_BASED_DEDUPLICATION, "false",
QueueAttributeName.DEDUPLICATION_SCOPE, "messageGroup",
QueueAttributeName.FIFO_THROUGHPUT_LIMIT, "perMessageGroupId",
QueueAttributeName.VISIBILITY_TIMEOUT, "30",
QueueAttributeName.REDRIVE_POLICY,
"{\"maxReceiveCount\":\"3\",\"deadLetterTargetArn\":\"%s\"}".formatted(dlqArn)
))
.build())
.join();
return response.queueUrl();
}
}
DeduplicationScope=messageGroup and FifoThroughputLimit=perMessageGroupId enable high-throughput FIFO mode. Each message group gets its own 3,000 TPS budget with batching rather than sharing 300 TPS across the whole queue. Use this when you have many independent groups running in parallel.
@Service
public class TradeEventPublisher {
private final SqsAsyncClient sqsClient;
private final String queueUrl;
public CompletableFuture<SendMessageResponse> publish(TradeEvent event) {
return sqsClient.sendMessage(SendMessageRequest.builder()
.queueUrl(queueUrl)
.messageBody(serialize(event))
.messageGroupId(event.accountId())
.messageDeduplicationId(event.eventId())
.build());
}
public CompletableFuture<SendMessageBatchResponse> publishBatch(List<TradeEvent> events) {
var entries = events.stream()
.map(event -> SendMessageBatchRequestEntry.builder()
.id(event.eventId())
.messageBody(serialize(event))
.messageGroupId(event.accountId())
.messageDeduplicationId(event.eventId())
.build())
.toList();
return sqsClient.sendMessageBatch(SendMessageBatchRequest.builder()
.queueUrl(queueUrl)
.entries(entries)
.build());
}
}
MessageGroupId is accountId — all events for the same account are ordered relative to each other. Events for different accounts are independent and are processed in parallel by different consumers.
MessageDeduplicationId should be a stable, deterministic identifier for the business event. Using the event’s own ID (a UUID generated at source) means retrying a send on network failure won’t create a duplicate — SQS will drop the second copy if it arrives within 5 minutes of the first.
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-starter-sqs</artifactId>
<version>3.2.0</version>
</dependency>
@Component
public class TradeEventConsumer {
private final TradeProcessor processor;
@SqsListener(
value = "${trade.queue.url}",
acknowledgementMode = SqsListenerAcknowledgementMode.ON_SUCCESS
)
public void onTradeEvent(
TradeEvent event,
@Header(SqsHeaders.SQS_MESSAGE_GROUP_ID_HEADER) String groupId) {
log.debug("Processing event {} for group {}", event.eventId(), groupId);
processor.process(event);
}
}
ON_SUCCESS means the message is deleted from the queue only when the method returns without throwing. If an exception propagates out, the message becomes visible again after the visibility timeout and is redelivered — up to maxReceiveCount times before it lands in the DLQ.
A FIFO DLQ must itself be a FIFO queue with the .fifo suffix:
@Bean
public String tradeEventDlqArn(SqsAsyncClient sqsClient) {
var response = sqsClient.createQueue(CreateQueueRequest.builder()
.queueName("trade-events-dlq.fifo")
.attributes(Map.of(
QueueAttributeName.FIFO_QUEUE, "true",
QueueAttributeName.CONTENT_BASED_DEDUPLICATION, "false",
QueueAttributeName.MESSAGE_RETENTION_PERIOD, "1209600" // 14 days
))
.build())
.join();
return sqsClient.getQueueAttributes(GetQueueAttributesRequest.builder()
.queueUrl(response.queueUrl())
.attributeNames(QueueAttributeName.QUEUE_ARN)
.build())
.join()
.attributes()
.get(QueueAttributeName.QUEUE_ARN);
}
Set a CloudWatch alarm on the DLQ’s ApproximateNumberOfMessagesVisible metric. When it’s non-zero, processing is failing consistently — you want to know immediately, not when a customer reports missing data.
SQS FIFO exactly-once delivery applies within the 5-minute deduplication window. Outside that window, or after a consumer crash before acknowledgement, redelivery can happen. Build idempotent handlers:
@Service
@Transactional
public class TradeProcessor {
private final TradeRepository repository;
public void process(TradeEvent event) {
if (repository.existsByEventId(event.eventId())) {
log.info("Skipping duplicate event {}", event.eventId());
return;
}
var trade = Trade.from(event);
repository.save(trade);
}
}
The eventId check uses the same identifier as MessageDeduplicationId. The SQS-level deduplication handles the first 5 minutes; the database idempotency check handles everything after that.
Setting CONTENT_BASED_DEDUPLICATION=true tells SQS to generate the deduplication ID from a SHA-256 hash of the message body. You don’t provide MessageDeduplicationId yourself. This is convenient but fragile: any change to the message body — whitespace, field order in JSON, added metadata — generates a different hash and allows a duplicate through.
Explicit deduplication IDs tied to your business event identifier are more reliable. Use content-based deduplication only for simple workloads where message body uniqueness is a genuinely safe assumption.
| Mode | Transactions per second |
|---|---|
| Standard queue | Effectively unlimited |
| FIFO (basic) | 300 TPS per queue |
| FIFO (high-throughput, batched) | 3,000 TPS per message group |
With batching of 10 and high-throughput mode, you can move ~30,000 messages per second through a single queue — sufficient for most ordered workloads. If you need more, split by domain across multiple FIFO queues rather than fighting the per-queue limit.
The ordering guarantee is per-group and per-delivery — not per-processing. If two threads dequeue from the same group simultaneously (which SQS prevents at the API level by keeping in-flight messages invisible), you’d lose order at the application level. Spring Cloud AWS handles this correctly out of the box with its sequential per-group dispatching.
If processing takes longer than the visibility timeout, the message becomes visible again and is delivered to another consumer while still being processed by the first. For FIFO queues this is especially harmful — it can create out-of-order processing within a group.
Set the visibility timeout to at least 3× the maximum expected processing time. If your handler calls a downstream API that can take up to 8 seconds, set the timeout to at least 30 seconds. Use ChangeMessageVisibility to extend it dynamically for unexpectedly slow messages:
sqsClient.changeMessageVisibility(ChangeMessageVisibilityRequest.builder()
.queueUrl(queueUrl)
.receiptHandle(receiptHandle)
.visibilityTimeout(60)
.build());
If you’re designing an event-driven system with SQS FIFO and want to get the ordering and reliability guarantees right from the start, hire me.