Parking failed messages in a dead letter queue
Open in the interactive app: practice, quiz, mock interview →In one minute
- A dead letter queue (DLQ) is where a messaging system puts messages that could not be processed after a bounded number of attempts.
- It stops a single bad message (a "poison pill") from blocking a queue or burning consumer capacity forever.
- A DLQ is only half a design: you also need alerting on it, tools to inspect messages, and a safe way to replay them.
- Separate transient failures (retry with backoff) from permanent ones (dead-letter immediately).
- An unwatched DLQ is silent data loss with extra steps.
How it works
Why messages fail repeatedly
Failures fall into two groups. Transient: a dependency timed out, a database was failing over, a rate limit hit. Retrying later usually succeeds. Permanent: malformed payload, schema version the consumer does not understand, a reference to an entity that does not exist, a bug triggered by specific data. Retrying a permanent failure only wastes capacity, and in an ordered queue it blocks everything behind it.
Mechanics in common brokers
In SQS, each receive increments a message's receive count; when it exceeds maxReceiveCount the queue moves it to the configured DLQ, and a redrive operation can move messages back (SQS dead-letter queues). RabbitMQ routes rejected or expired messages to a dead letter exchange configured on the queue, with headers recording the reason and original queue. Watch the guarantee: classic queues republish dead letters without confirms, so a message can be lost if the target is unavailable, while quorum queues can dead-letter at least once (RabbitMQ dead lettering). Azure Service Bus has a built-in dead-letter subqueue per queue, with a reason and description; the default delivery limit is 10, and messages in the DLQ never expire, so someone must drain it (Service Bus DLQ).
Kafka consumer groups have no built-in DLQ because consumers own their offsets; frameworks and consumers publish failed records to a separate topic, often orders.dlq, then commit the offset and move on. Kafka Connect does this only for sink connectors (Confluent: Kafka Connect concepts), and Kafka Streams gained DLQ support in its exception handlers in 4.2 (Kafka 4.2 release). Kafka's newer share groups count deliveries per record, but a record that hits the limit (5 by default) or is rejected is simply archived; there is no DLQ for it yet (KIP-932).
Retry tiers
A common pattern is a ladder: try inline a few times with short backoff; then publish to a retry topic or delayed queue (for example 1 minute, then 10 minutes, then 1 hour); then the DLQ. This keeps the main consumer fast while still giving transient failures room to recover. Each hop should carry metadata: original topic, partition and offset, attempt count, first failure time, last error class and message.
Ordering
Moving a message aside breaks ordering for its key. If event 2 for order 42 is dead-lettered and event 3 is processed, state can go wrong. Options: accept it where events are independent; park all later messages for that key until the failed one is resolved; or design events as full-state snapshots so later ones supersede earlier ones.
Replay
Replaying sends DLQ messages back to the main queue once the cause is fixed. It needs idempotent consumers, because some of these messages may have partially applied. It also needs rate limiting, because 2 million messages released at once can overload the system that just recovered.
Down the stack
Layer 1The contract
In SQS, a received message becomes invisible for the visibility timeout; if the consumer does not delete it in time, it becomes visible again and its receive count rises. Once a receive would push the count past maxReceiveCount, the queue moves the message to the DLQ instead of delivering it. In RabbitMQ, a message is dead-lettered when it is rejected without requeue, expires by TTL, overflows a length limit, or exceeds a quorum queue's delivery limit. Since RabbitMQ 4.0 that limit defaults to 20, and without a dead-letter exchange the message is dropped, not parked (RabbitMQ quorum queues). The DLQ itself promises nothing beyond storage: no alerting, no replay, no ordering relative to the source.
Layer 2The mechanism
An SQS queue's redrive policy is a small JSON document naming the DLQ's ARN and maxReceiveCount. Every receive returns a fresh receipt handle, which is what DeleteMessage and ChangeMessageVisibility need, so a slow consumer can extend its lease. Redrive back to the source is a message move task with an optional rate cap of at most 500 messages per second, and only one task can run per queue at a time (StartMessageMoveTask). One quirk: on a standard queue with maxReceiveCount above 3, a message received 3 times without being deleted is moved to the back of the queue, so ApproximateAgeOfOldestMessage stops showing it. In RabbitMQ, queue arguments x-dead-letter-exchange and x-dead-letter-routing-key route dead messages, and each message gains an x-death header listing reason, source queue, count and time; quorum queues track a delivery count in their Raft log. Kafka has no broker-side DLQ because a partition is an append-only log: a consumer cannot skip one record and return to it later without committing past it. So frameworks copy the record to another topic: Kafka Connect with errors.tolerance=all and errors.deadletterqueue.topic.name, adding __connect.errors.* headers, or retry topics with suffixes such as -retry-0.
Layer 3The machine
Visibility is a per-message timestamp and receive count stored in a replicated backend, so each receive and delete is a network round trip of a few milliseconds. Poison messages often hurt more than they fail: a payload that triggers an out-of-memory error crashes the consumer process, and a JVM restart takes 10 to 30 seconds, so one bad message can take a consumer offline repeatedly until the count trips. SQS caps a message at 1 MiB including attributes (it was 256 KiB until August 2025), so a large payload plus a full stack trace can still fail to fit (SQS quotas); keep error metadata short and put big payloads in object storage with a pointer. Kafka DLQ topics keep the default 7-day retention unless changed, which is often shorter than the time it takes someone to notice (Kafka topic configs).
Why it's built this way
- A broker cannot tell a slow consumer from a dead one, so it uses leases and redelivers on timeout.
- Redelivery of a message that always fails would loop forever, so brokers count receives and stop at a limit.
- Logs cannot skip one entry and come back, so log-based systems copy failures to a separate topic and move on.
- Failures that code cannot fix need humans, so dead messages must keep their context and live long enough to be triaged.
- Recovery itself creates load spikes, so replay must be rate-limited.
Diagram
flowchart LR
P[Producer] --> Q["Main queue"]
Q --> C[Consumer]
C -->|success| A["Ack, delete"]
C -->|"transient, attempts < 5"| R["Retry queue, backoff"]
R --> Q
C -->|"permanent, or attempts = 5"| D["Dead letter queue"]
D --> M["Alert + inspect tool"]
M -->|"fix deployed, rate-limited redrive"| QWorked example
An order service processes 3,000 messages per second from SQS. A deploy introduces a bug that fails on orders with a discount code, about 4% of traffic.
With maxReceiveCount 5 and a visibility timeout of 30 seconds, each failing message is received 5 times over about 2.5 minutes before landing in the DLQ. Failing traffic is 120 per second, but each consumes 5 attempts, so consumers spend 600 attempts per second, 20% of capacity, on doomed work. If the consumer fleet ran at 85% utilisation, the healthy traffic now queues and latency climbs.
The DLQ depth alarm (any message older than 5 minutes, or depth above 100) fires 3 minutes after the deploy. The team rolls back after 20 minutes: 120 x 1,200 seconds = 144,000 messages in the DLQ. They inspect a sample, confirm all share the error DiscountNotFound, deploy the fix, and redrive at 500 messages per second (the highest fixed rate SQS allows), taking about 5 minutes, while watching consumer error rates. Because order creation is idempotent on order_id, the few messages that had partially succeeded do not create duplicates.
Without a DLQ, those messages would have retried forever, consuming 20% of capacity until the fix, or been dropped by a catch-and-log handler.
In the wild
- Uber, 2018: retry topics and a DLQ so one failure does not block a partition. Uber's insurance team, running the Driver Injury Protection program in more than 200 cities, found that while a handler kept failing the consumer would not commit a new offset, so the batches holding the bad messages stayed blocked. They moved failures to a chain of retry topics, each with a longer delay and its own consumers, ending in a DLQ topic that operators can list, purge, or merge back into the first retry topic without disturbing live traffic (Uber Engineering).
- GitHub, 2018: failed deliveries outlive their retention. During a 24-hour degradation after a database failover, more than 5 million webhook events queued and about 200,000 webhook payloads passed their internal TTL and were dropped before delivery. Parked messages are only recoverable while they are still retained, so DLQ and backlog retention must be longer than the worst outage you plan for (GitHub).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Broker-native DLQ (SQS, Service Bus) | Built in, automatic receive counting | Coarse: same retry policy for all errors | Simple queues with independent messages |
| Retry topics plus DLQ topic (Kafka) | Delayed retries without blocking partition | More topics, ordering per key is lost | High-throughput streams |
| Immediate DLQ for permanent errors | No wasted retries | Requires accurate error classification | Validation and schema failures |
| Block partition on failure | Preserves ordering | One bad message stalls everything | Strict ordering, low volume |
| Drop and log | Simplest | Silent data loss | Truly disposable messages only |
Pitfalls in production
- Nobody watches the DLQ. Messages sit for weeks until retention deletes them. Alarm on depth and on age of oldest message, routed to the owning team.
- DLQ retention shorter than source. For standard queues SQS keeps the original enqueue timestamp, so a message near the end of its retention expires soon after landing; FIFO queues reset the timestamp on the move. Set DLQ retention longer than the source's, ideally the maximum.
- Missing context. A DLQ message with no error or origin cannot be triaged. Attach error class, stack summary, attempt count and source metadata.
- Retrying permanent errors. Malformed payloads burn retries. Classify exceptions and dead-letter non-retryable ones immediately.
- Replaying everything at once. The recovered system falls over again. Redrive with a rate limit and watch error rates.
- Visibility timeout shorter than processing. Healthy slow messages get redelivered, counted as failures, and dead-lettered. Set the timeout above p99 processing time, or extend it while working.
Leadership lens
- Every queue gets a DLQ, an alarm and an owner on day one; make it part of the service template.
- Write the replay runbook before the first incident, including who approves redriving financial messages.
- Ask in review: which errors are retryable, and what happens to ordering when one message is set aside?
- Track DLQ inflow as a quality metric; a steady trickle usually means a contract mismatch between teams.
Check yourself
Why should a schema validation error skip retries and go straight to the DLQ?
Show answer
It is permanent: the same payload will fail every time, so retries only waste capacity and delay other messages.
Why must consumers be idempotent before you redrive a DLQ?
Show answer
Some messages may have partially succeeded before failing, so reprocessing must not duplicate effects that already happened.
How can a too-short visibility timeout fill a DLQ with valid messages?
Show answer
Slow but healthy processing exceeds the timeout, the message is redelivered and its receive count climbs until it crosses the limit.
Sources and further study
Primary
- KIP-932: Queues for Kafka: delivery counts, the REJECT outcome and archiving, Kafka's first broker-side poison-message limit.
- Apache Kafka 4.2.0 release announcement: DLQ support in Kafka Streams exception handlers.
Systems docs
- RabbitMQ: Dead lettering: the four dead-letter reasons, the
x-deathheader, and at-most-once versus at-least-once dead-lettering. - RabbitMQ: Quorum queues: delivery limits and why 4.0 set a default of 20.
- Amazon SQS: StartMessageMoveTask: redrive destinations, rate limits and one task per queue.
- Confluent: Kafka Connect concepts:
errors.tolerance, the sink-only DLQ topic and error-context headers.
Books
- Designing Data-Intensive Applications (2nd ed.), Martin Kleppmann and Chris Riccomini, Ch 12 "Stream Processing": how log-based brokers handle a message a consumer cannot process.
- Release It! (2nd ed.), Michael T. Nygard, Ch 5 "Stability Patterns": timeouts, circuit breakers, bulkheads and fail fast, the habits that keep one bad input from becoming an outage.
Other curricula
- Azure Architecture Center: Competing Consumers pattern: frames poison-message detection as a core consideration and points to dead-lettering after
MaxDeliveryCount. - Azure Architecture Center: Idempotent Consumer pattern: reminds you that DLQ resubmissions arrive long after the normal redelivery window, so dedupe records must outlast them.
Watch
- Dead Letter Queues - System Design (Greg Hogg): what DLQs are for and how they fit a queue design.
- Errors and Dead Letter Queues, Kafka Connect 101 (Confluent): DLQ handling in a Kafka pipeline.
Free resources
- Amazon SQS dead-letter queues: receive counts, redrive and retention caveats.
- Azure Service Bus dead-letter queues: dead-letter reasons and subqueue behaviour.
- Dead Letter Channel (Enterprise Integration Patterns): the original pattern description.
- Avoiding insurmountable queue backlogs (AWS Builders' Library): poison messages, backlogs and recovery.