System designCore11 min
Dead-letter queues & poison messages
Some messages will never succeed, however often they are retried. Left in place, one of them wastes workers or blocks everything behind it. A dead-letter queue moves such messages aside so the rest can flow, keeps them for someone to inspect, and lets them be replayed once the cause is fixed. Getting it right means telling bad messages from a broken consumer, keeping order where it matters, and replaying safely.
1 · The problem
A message that can never succeed blocks or burns the work around it
A parcel company scans each parcel at every step: picked up, sorted, out for delivery, delivered. About two million scan events a day flow through a broker to two consumers: one updates the public tracking page, the other texts customers. One morning a scanner firmware update starts sending ZIP codes as numbers, so "02139" arrives as 2139, and the tracking consumer rejects every event from those scanners.
Such an event is a poison message: retrying it changes nothing, because the problem is in the message itself. What it costs depends on the broker:
- In a queue, the message reappears after each failure and is retried forever, taking a worker's time on every attempt (Message queues & pub/sub).
- In a log, such as a Kafka partition, the consumer can't move past it without skipping it. Every event behind it, for thousands of other parcels, waits.
The consumer can't commit offset 81,204, so it reads it forever. Thousands of valid events behind it are never processed.
A dead-letter queue (DLQ) is the way out: after a set number of failed attempts, the message is moved to a separate queue or topic, the consumer moves on, and someone is alerted to look at it. The basic mechanism is on Message queues & pub/sub; this page is about using it well.
2 · Classify
Dead-letter what a retry can't fix, and stop when everything fails
Not every failure belongs in a DLQ. The consumer should ask why a message failed, because each cause has a different cure:
Cause | At the parcel company | Right response |
|---|---|---|
Transient | The text-message provider times out | Retry with backoff; it will likely pass |
Permanent, in the data | ZIP code 2139, a parcel ID that doesn't exist | Dead-letter at once; retrying wastes attempts |
Crashes the consumer | A 40 MB event that runs a worker out of memory | Count attempts, dead-letter after the limit |
Bug in the consumer | A bad deploy rejects every event | Stop and roll back; don't dead-letter the whole stream |
The last row is the trap. A DLQ is for the few messages that are wrong. If a deploy breaks the consumer, every message fails, and a DLQ with a low attempt limit quietly fills with two million perfectly good events while the dashboards look calm.
Two defenses:
- Alert on the rate of dead-lettering, not just on whether the DLQ is empty. One event a day is noise; ten thousand an hour is an outage.
- Fail fast on systemic errors. Kafka Connect's default is to stop a connector at the first bad record, on the reasoning that bad data may be a symptom of an upstream problem that must be fixed first.
For known permanent errors, the consumer can dead-letter a message on the first attempt instead of waiting out the retries. Azure Service Bus, for example, lets an application dead-letter a message explicitly with its own reason code.
3 · The brokers
Every broker counts attempts differently, and the defaults matter
The idea is the same everywhere; the details differ in ways that bite:
Broker | Moves a message after | Default | Worth knowing |
|---|---|---|---|
Amazon SQS | maxReceiveCount receives | None until configured | DLQ type must match: FIFO with FIFO |
RabbitMQ | Reject without requeue, TTL, length limit, or delivery limit | Quorum queues: 20 since 4.0 | Reasons recorded in an x-death header |
Google Pub/Sub | Max delivery attempts, 5 to 100 | Set when enabled | Counting is approximate, best effort |
Azure Service Bus | MaxDeliveryCount | 10 | No automatic cleanup of the DLQ |
Three details cause real losses:
Detail | What goes wrong | Fix |
|---|---|---|
Retention runs from the original send | SQS standard queues: 1 day in the source, then a DLQ with 4-day retention, means deletion 3 days later | Make DLQ retention longer than the source's |
Dead-lettering can lose messages | RabbitMQ republishes dead letters without publisher confirms by default; if the target is unavailable they can be lost | Quorum queues' at-least-once dead-lettering |
Receives aren't failures | SQS counts receives, so a consumer that crashes right after receiving still uses up an attempt | Set the limit with crashes in mind |
Record why a message was dead-lettered, not just that it was. In Service Bus, the exception type goes in the dead-letter reason and the stack trace in the description; RabbitMQ keeps the queue and reason of each dead-lettering in the message. Without that, whoever opens the DLQ starts by reproducing the error.
4 · In a log
On a log, the consumer builds its own retry topics and DLQ
A classic Kafka consumer group has no per-message acknowledgment, so it has no built-in DLQ. (Share groups, production-ready since Kafka 4.2, add per-record acknowledgment and delivery counts.) In a classic group the consumer has to move a failing event aside itself: publish it to another topic, then commit its offset so the partition keeps flowing. A common design adds retry topics with growing delays between the main topic and the DLQ:
Each retry topic has its own consumer that waits until the event is old enough before trying it. The main partition never stops. The delays shown are this example's choice.
Kafka Connect has this built in for connectors: errors.tolerance = all with errors.deadletterqueue.topic.name routes bad records to a DLQ topic, and errors.deadletterqueue.context.headers.enable = true attaches the reason to each one. Without those settings, the default is to stop at the first error.
5 · Order
Moving one message aside lets later ones overtake it
Parcel 5513's "out for delivery" event fails on a transient error and goes to a retry topic. Ten seconds later its "delivered" event succeeds. Then the retry runs:
The "out for delivery" event failed once and was moved to the retry topic. When it is retried, the page goes backwards. Retry topics and DLQs trade per-key order for a flowing partition, and the consumer must be ready for that.
So don't use a DLQ with a FIFO queue in SQS if you can't afford to break the exact order of messages. Retry topics accept the same condition: the application must tolerate events handled out of order.
There are two ways to tolerate it:
- Make updates order-proof. Each scan carries a sequence number or the time it happened, and the consumer ignores any event older than what it already applied. The late "out for delivery" is then dropped, and so is a stale one replayed from the DLQ next week.
- Park the key. Once an event for parcel 5513 is set aside, set aside every later event for 5513 too, and release them together in order. This keeps order but needs a record of parked keys.
The first is usually simpler and also makes replays safe, which the next section needs anyway (Idempotency).
6 · Redrive
A DLQ is a to-do list: inspect, fix, then replay at a controlled rate
Messages in a DLQ aren't handled; they are waiting for a decision. Service Bus never cleans its DLQ automatically, and SQS expires messages by their original age, so an ignored DLQ either grows forever or quietly loses data. For the ZIP code events, the response goes like this:
- Alert
The dead-letter rate for scans jumps from about zero to 900 an hour. The on-call engineer is paged.
- Inspect
The recorded reason, "invalid ZIP code", and the source scanner IDs point at one firmware version.
- Fix
The consumer learns to restore the leading zeros for that firmware, and the scanner vendor is told.
- Replay at a controlled rate
The events are moved back to the main queue, throttled so the replay doesn't swamp live traffic. SQS's redrive can move them back to the source queue at a set velocity.
- Decide on the rest
Events that are still wrong, such as scans of parcels that don't exist, are recorded and discarded deliberately, not left to expire.
Replayed messages arrive late and possibly twice, since some may have been partly processed before failing. Order-proof, idempotent consumers make that harmless. Also note that a replay can give messages a new identity: SQS treats redriven messages as new, with a new message ID and enqueue time, so deduplication must rely on the event's own ID, such as the scan ID, not the broker's.
7 · In practice
Every DLQ needs an owner, an alert and a replay path
A DLQ without an owner is a slower way of losing data. For the parcel company, the checklist:
- Classify failures in the consumer: retry transient ones, dead-letter permanent ones at once.
- Set the attempt limit high enough for ordinary hiccups, and know what your broker counts.
- Alert on the dead-letter rate, and treat a flood as a consumer bug, not as bad data.
- Record the reason, source and attempt count with every dead-lettered message.
And for keeping and replaying them:
- Keep DLQ retention longer than the source's, and clean it deliberately.
- Make consumers order-proof and idempotent, using the event's own ID and version.
- Replay at a controlled rate, only after the fix is deployed.
- On a log, publish failures to retry topics and commit, so one event never blocks a partition.
Practice | What goes wrong without it |
|---|---|
Classify failures | A malformed scan waits out every retry before it is set aside |
Alert on the dead-letter rate | A consumer bug dead-letters thousands of scans unnoticed |
Record the reason | Nobody can tell why a scan failed, so nobody can fix it |
Keep the DLQ longer than the source | Messages expire before anyone looks at them |
Replay after the fix, at a set rate | Replayed scans fail again, or flood the consumer |
Dead-letter queues are the last stop after retries (Timeouts, retries, backoff & jitter); background-job systems keep the same idea under other names, such as Sidekiq's dead set (Background jobs & scheduling).
Check yourself
4 questions
Takeaways
Remember this
- A poison message fails on every attempt; without a DLQ it loops in a queue or blocks a log partition.
- Retry transient failures, dead-letter permanent ones, and treat a flood of dead-lettering as a broken consumer.
- Brokers count attempts differently; record the reason with each message and keep DLQ retention longer than the source's.
- Setting a message aside breaks per-key order, so consumers must ignore stale events or park the key.
- A DLQ needs an owner, an alert on its rate, and a throttled replay into idempotent consumers after the fix.