In any distributed system that processes messages asynchronously, failures are inevitable. A malformed payload, a downstream service being temporarily unavailable, a bug in your consumer code — any of these can cause a message to fail processing. Without a safety net, that message either gets silently dropped or causes your consumer to loop on it forever, blocking the rest of the queue. AWS SQS Dead Letter Queues (DLQs) are that safety net.
This guide walks through how SQS DLQs work under the hood, how to configure them correctly, how to integrate them with Spring Boot, how to monitor them with CloudWatch, and — critically — how to redrive failed messages back to the source queue once you have fixed the underlying issue.
What is a Dead Letter Queue?
A Dead Letter Queue is a standard SQS queue that receives messages that could not be successfully processed after a configured number of attempts. It is not a special AWS resource — it is just a regular SQS queue that you designate as the failure destination for another queue (the source queue). AWS handles the routing automatically once you configure the relationship.
The core concept is maxReceiveCount: the number of times SQS will allow a consumer to receive a message before considering it a poison pill and moving it to the DLQ. If a message is received but not deleted within the visibility timeout window, SQS makes it available again for retry. After maxReceiveCount such cycles, SQS moves it to the DLQ automatically.
How SQS message processing works
Understanding the full SQS message lifecycle is essential before configuring a DLQ correctly:
┌─────────────────────────────────────────────────────────────┐
│ SQS Message Lifecycle │
├─────────────────────────────────────────────────────────────┤
│ │
│ Producer ──► [Source Queue] │
│ │ │
│ ▼ │
│ Consumer receives message │
│ (message becomes invisible) │
│ │ │
│ ┌──────────┴──────────┐ │
│ │ │ │
│ Processing Visibility timeout │
│ succeeds expires (no ack) │
│ │ │ │
│ ▼ ▼ │
│ Delete message Message reappears │
│ (ack / done) receiveCount++ │
│ │ │
│ receiveCount >= maxReceiveCount? │
│ │ │ │
│ YES NO │
│ │ │ │
│ ▼ └─► retry │
│ ┌──────────┐ │
│ │ DLQ │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────┘Key parameters that interact with DLQ behaviour:
- Visibility Timeout — how long a message is hidden from other consumers after being received. If your consumer doesn't delete the message within this window, SQS assumes it failed and makes it available again. Must be longer than your maximum processing time.
- maxReceiveCount — the threshold at which SQS routes the message to the DLQ. A value of 3–5 is typical. Too low and transient failures get DLQ'd unnecessarily; too high and a bad message blocks your queue for too long.
- Message Retention Period — how long messages stay in the DLQ before being automatically deleted. Set this high enough (e.g., 14 days) that you have time to investigate and redrive.
Creating a DLQ — AWS Console and CloudFormation
AWS Console (quickest for testing)
In the AWS Console, create a standard SQS queue (e.g., orders-dlq). Then edit your source queue (orders-queue), go to the Dead-letter queue section, select your DLQ, and set Maximum receives (this is maxReceiveCount).
CloudFormation / AWS CDK
# CloudFormation template
Resources:
OrdersDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: orders-dlq
MessageRetentionPeriod: 1209600 # 14 days in seconds
OrdersQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: orders-queue
VisibilityTimeout: 300 # 5 minutes
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrdersDLQ.Arn
maxReceiveCount: 5// AWS CDK (TypeScript)
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { Duration } from 'aws-cdk-lib';
const dlq = new sqs.Queue(this, 'OrdersDLQ', {
queueName: 'orders-dlq',
retentionPeriod: Duration.days(14),
});
const ordersQueue = new sqs.Queue(this, 'OrdersQueue', {
queueName: 'orders-queue',
visibilityTimeout: Duration.minutes(5),
deadLetterQueue: {
queue: dlq,
maxReceiveCount: 5,
},
});Spring Boot integration with AWS SQS
The spring-cloud-aws-starter-sqs library (Spring Cloud AWS 3.x) provides the @SqsListener annotation for consuming SQS messages in Spring Boot applications. Here is a complete setup:
Dependency (pom.xml)
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-starter-sqs</artifactId>
<version>3.1.1</version>
</dependency>application.yml
spring:
cloud:
aws:
region:
static: ap-south-1
credentials:
# Use IAM role in production — never hardcode keys
access-key: ${AWS_ACCESS_KEY_ID}
secret-key: ${AWS_SECRET_ACCESS_KEY}
app:
queues:
orders: orders-queue
orders-dlq: orders-dlqConsumer — source queue
@Service
@Slf4j
public class OrderMessageConsumer {
private final OrderService orderService;
public OrderMessageConsumer(OrderService orderService) {
this.orderService = orderService;
}
@SqsListener(value = "${app.queues.orders}")
public void handleOrder(@Payload OrderEvent event,
@Headers MessageHeaders headers) {
log.info("Processing order: {}", event.getOrderId());
try {
orderService.process(event);
// Returning without exception = message deleted from queue (ack)
} catch (TransientException e) {
// Throw to let SQS retry (message becomes visible again after timeout)
log.warn("Transient failure for order {}, will retry", event.getOrderId(), e);
throw e;
} catch (PermanentException e) {
// Re-throw even though no retry will help. Throwing is what makes SQS count
// this receive, and maxReceiveCount is what eventually routes the message to
// the DLQ. The alternative — send it to the DLQ yourself and return normally —
// is faster and is covered further down.
log.error("Permanent failure for order {}", event.getOrderId(), e);
throw e;
}
}
}DLQ consumer — for alerting and inspection
@Service
@Slf4j
public class OrderDlqConsumer {
private final AlertService alertService;
private final DeadLetterRepository dlqRepository;
public OrderDlqConsumer(AlertService alertService, DeadLetterRepository dlqRepository) {
this.alertService = alertService;
this.dlqRepository = dlqRepository;
}
@SqsListener(value = "${app.queues.orders-dlq}")
public void handleDlqMessage(
@Payload String rawMessage,
@Header(SqsHeaders.SQS_SOURCE_DATA_HEADER) Message sqsMessage) {
// SQS_SOURCE_DATA_HEADER carries the original AWS SDK Message, so ask it for the
// id. MessageHeaders.ID — what headers.get("id") returns — is a UUID generated in
// this JVM, and correlates with nothing you can see in the SQS console.
String sqsMessageId = sqsMessage.messageId();
log.error("Message landed in DLQ. sqsMessageId={}, body={}", sqsMessageId, rawMessage);
dlqRepository.save(new DeadLetterRecord(sqsMessageId, rawMessage, Instant.now()));
alertService.sendDlqAlert(sqsMessageId, rawMessage);
// Returning normally acks the DLQ message, so it will not re-appear. You redrive
// from the saved record instead.
}
}Note: Whether to consume DLQ messages automatically in your application or leave them in the DLQ for manual inspection/redriving depends on your team's operations workflow. For most teams, storing DLQ arrivals in a database and alerting is the right pattern — it gives you a dashboard of failures without losing the messages.
Distinguishing transient from permanent failures
The most important architectural decision with DLQs is defining what should retry and what should fail immediately. A good heuristic:
// Transient — SHOULD retry (throw exception, let SQS retry)
- HTTP 503 / 429 from downstream service
- Database connection timeout
- Temporary lock contention
- Downstream service deployment / restart
// Permanent — SHOULD NOT retry (log + manual fix needed)
- JSON deserialization failure (message is malformed)
- Business rule violation (e.g., order total is negative)
- Missing required field in payload
- Referential integrity error (entity doesn't exist)
// Rule of thumb: if retrying 100 times wouldn't fix it, it's permanent.For permanent failures, some teams prefer to send directly to the DLQ via the AWS SDK rather than waiting for maxReceiveCount to exhaust:
// Constructor-injected like everything else here — @Autowired on a field was
// the odd one out.
private void sendToDlqImmediately(OrderEvent event, String rawMessage) {
sqsTemplate.send(to -> to.queue(dlqQueueName).payload(rawMessage));
log.error("Sent order {} straight to the DLQ, skipping the retry budget",
event.getOrderId());
// Returning normally acks the source message, so SQS deletes it and no
// retries are spent on a failure that cannot succeed.
}Monitoring DLQ with CloudWatch
A DLQ is only useful if you know when messages arrive in it. Set up a CloudWatch alarm on ApproximateNumberOfMessagesVisible for your DLQ:
# CloudFormation alarm
DLQAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: orders-dlq-not-empty
AlarmDescription: "Messages are arriving in the orders DLQ"
Namespace: AWS/SQS
MetricName: ApproximateNumberOfMessagesVisible
Dimensions:
- Name: QueueName
Value: orders-dlq
Statistic: Sum
Period: 60
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref OpsTeamSNSTopicThis alarm fires the moment a single message lands in the DLQ, triggering an SNS notification to your team's Slack or PagerDuty. Never leave a DLQ unmonitored — silent DLQ accumulation is one of the most common causes of data loss in event-driven systems.
Redriving messages from DLQ to source queue
Once you have fixed the bug that caused the failures, you need to replay the DLQ messages through the source queue. AWS provides built-in Dead-letter queue redrive in the console (introduced in 2021), but you can also do it programmatically:
AWS Console redrive (simplest)
Navigate to your DLQ in the SQS console → click Start DLQ redrive → choose Redrive to source queue → set a velocity (messages/second) → start. AWS handles the rest. You can pause and resume the redrive job.
Programmatic redrive (Java)
@Service
@Slf4j
public class DlqRedriveService {
private static final int EMPTY_RECEIVES_BEFORE_STOPPING = 3;
private final SqsClient sqsClient;
private final String dlqUrl;
private final String sourceQueueUrl;
public DlqRedriveService(SqsClient sqsClient,
@Value("${app.queues.orders-dlq-url}") String dlqUrl,
@Value("${app.queues.orders-url}") String sourceQueueUrl) {
this.sqsClient = sqsClient;
this.dlqUrl = dlqUrl;
this.sourceQueueUrl = sourceQueueUrl;
}
public void redriveAll() {
int redriven = 0;
int consecutiveEmpty = 0;
// An empty receive on a standard queue does NOT mean the queue is empty — SQS
// samples a subset of its servers on each call. Long-poll, and require several
// empty receives in a row before deciding you are done.
while (consecutiveEmpty < EMPTY_RECEIVES_BEFORE_STOPPING) {
ReceiveMessageResponse response = sqsClient.receiveMessage(req -> req
.queueUrl(dlqUrl)
.maxNumberOfMessages(10)
.waitTimeSeconds(20)
);
if (response.messages().isEmpty()) {
consecutiveEmpty++;
continue;
}
consecutiveEmpty = 0;
for (Message message : response.messages()) {
// Forward to source queue
sqsClient.sendMessage(req -> req
.queueUrl(sourceQueueUrl)
.messageBody(message.body())
.messageAttributes(message.messageAttributes())
);
// Delete from DLQ
sqsClient.deleteMessage(req -> req
.queueUrl(dlqUrl)
.receiptHandle(message.receiptHandle())
);
redriven++;
}
}
log.info("Redriven {} messages from the DLQ to the source queue", redriven);
}
}Common DLQ mistakes and how to avoid them
- Setting maxReceiveCount too low (1 or 2): A single transient network blip will send legitimate messages to the DLQ. Use at least 3, preferably 5, unless your processing is truly idempotent and you want fast failure routing.
- Visibility timeout shorter than processing time: If your consumer takes 4 minutes to process a message and the visibility timeout is 3 minutes, SQS will make the message visible again while you're still processing it. Size the timeout on your worst case, not your average — a timeout sized on the average expires during every slower-than-average message. My own rule of thumb is p99 plus a healthy margin.
- Not monitoring the DLQ: A DLQ with no CloudWatch alarm is just a bin where messages silently disappear. Always add an alarm.
- Using FIFO source queue with standard DLQ: A FIFO queue's DLQ must also be a FIFO queue. Mixing queue types causes AWS to reject the configuration.
- Redriving without fixing the root cause: Messages will just fail again and return to the DLQ. Fix the bug first, deploy, then redrive.
- Short DLQ message retention: The default 4-day retention is often too short for production incidents that take a weekend to diagnose. Set retention to 14 days (the maximum) for critical queues.
DLQ pattern for FIFO queues
FIFO queues guarantee ordering, and exactly-once processing through deduplication within a five-minute deduplication interval — which is a narrower promise than "exactly-once delivery," and worth being precise about. A failing message can block the entire message group, since FIFO preserves order per MessageGroupId. A stuck message in a FIFO queue will hold up all messages with the same group ID until the failing message is either processed successfully or moved to the DLQ. Keep maxReceiveCount low for FIFO queues (2–3) to avoid prolonged blockage, and design your MessageGroupId granularity carefully.
// FIFO DLQ in CDK
const fifoOrdersDlq = new sqs.Queue(this, 'FifoOrdersDLQ', {
queueName: 'orders-dlq.fifo',
fifo: true,
retentionPeriod: Duration.days(14),
});
const fifoOrdersQueue = new sqs.Queue(this, 'FifoOrdersQueue', {
queueName: 'orders-queue.fifo',
fifo: true,
contentBasedDeduplication: true,
visibilityTimeout: Duration.minutes(5),
deadLetterQueue: {
queue: fifoOrdersDlq,
maxReceiveCount: 3,
},
});DLQ architecture checklist
- Every production SQS queue has a DLQ configured
maxReceiveCountis set to 3–5 (not 1)- DLQ message retention is 14 days
- Source queue visibility timeout exceeds worst-case processing time (p99 + margin)
- CloudWatch alarm on DLQ
ApproximateNumberOfMessagesVisible > 0 - Alarm routes to SNS → Slack or PagerDuty
- Redrive procedure is documented in your runbook
- FIFO DLQ is also FIFO (if source is FIFO)
Frequently Asked Questions
Does AWS charge for messages in the DLQ?
Only for requests. SQS bills per request plus data transfer, and there is no storage charge — approximately $0.40 per million requests on standard queues, higher on FIFO, and it varies by region. So a DLQ holding thousands of messages nobody is polling costs nothing to keep. You pay for the requests that put them there, and for each receive and delete when you drain or redrive. Long retention is therefore cheap; keep it at 14 days.
Can I use the same DLQ for multiple source queues?
Yes, but it is generally not recommended for production. When multiple source queues share a DLQ, you lose the ability to easily identify which queue a failed message came from. If you do share a DLQ, add a SourceQueue message attribute when forwarding manually, or read the DeadLetterQueueSourceArn attribute that AWS adds automatically. A dedicated DLQ per source queue is cleaner and easier to monitor individually.
What happens to message attributes when a message moves to the DLQ?
All original message attributes are preserved when SQS moves a message to the DLQ. AWS also adds a system attribute DeadLetterQueueSourceArn so you can trace the origin. The original message body is also unchanged — you receive exactly what the producer sent.
What is the difference between a DLQ and SNS dead-lettering?
SNS also supports dead-lettering for failed subscription deliveries (when SNS cannot deliver a notification to an SQS, Lambda, or HTTP endpoint). SNS dead-lettering and SQS dead-lettering are separate features at different layers. SNS DLQ captures failures in the SNS-to-subscriber delivery step, while SQS DLQ captures failures in the consumer-to-queue processing step. In an SNS → SQS → Lambda pipeline, you might have both configured at different points.
How do I test DLQ behaviour locally?
Use LocalStack — an open-source tool that emulates AWS services locally including SQS and DLQ behaviour. Run it with Docker:docker run -p 4566:4566 localstack/localstack. Configure your Spring Boot application to point to http://localhost:4566 for the SQS endpoint. You can create queues, set DLQ policies, and observe message routing without any AWS costs or internet access required.
Should the DLQ consumer ack (delete) messages or leave them?
It depends on your strategy. If you are consuming DLQ messages in real-time to store them in a database for later redriving, you should ack (delete) them — otherwise they will re-appear and be processed again after the visibility timeout. If you prefer to use AWS's built-in redrive feature from the DLQ directly, do not consume the DLQ at all — just monitor it with CloudWatch and redrive when ready. Pick one approach and be consistent.