Dead Letter Queues: Handling Poison Messages in Distributed Systems
Stop poison messages from blocking queues
A dead-letter queue is the safety net that catches messages your consumers cannot process, so one broken payload does not block or silently drop everything behind it in the queue.
Every message-driven system eventually receives a message it cannot handle: a malformed payload, a schema that changed underneath the consumer, or a downstream call that fails no matter how many times you retry it. Without a dead-letter queue, that message either blocks the head of the queue forever or gets silently discarded, and both outcomes are worse than knowing about the failure.
A DLQ turns an invisible failure into a visible, inspectable one. It gives you a place to quarantine the message, alert on it, and decide — deliberately, not by accident — whether to fix and replay it or discard it for good.

The mechanics differ across brokers, but the underlying pattern is the same everywhere: a delivery-attempt counter, a threshold, and a destination for messages that cross it. This guide covers what a DLQ actually does, how to tell a poison message from a transient failure, when to retry versus discard, and how to replay safely once you have fixed the root cause. For the broader integration-patterns context this pattern sits inside, see the App Architecture.
What Is a Dead Letter Queue
A dead-letter queue is a separate, ordinary queue that a broker or consumer routes a message to after that message fails processing too many times. It is not a special construct — RabbitMQ’s dead-letter queue is a regular queue bound to a regular exchange, and an SQS DLQ is a regular standard or FIFO queue. What makes a queue a “DLQ” is purely that something else points failed messages at it.
Each broker implements the redirect differently:
- Amazon SQS uses a redrive policy with a
maxReceiveCount. Once a message has been received that many times without being deleted, SQS moves it to the configureddeadLetterTargetArn. AWS explicitly recommends keeping the DLQ’s message retention period longer than the source queue’s, because the original enqueue timestamp — not the move time — still governs expiry. - RabbitMQ dead-letters a message when it is rejected with
requeue=false, its per-message TTL expires, the queue hits a length limit, or a quorum queue exceeds itsdelivery-limit. You configure this with thex-dead-letter-exchange(and optionallyx-dead-letter-routing-key) queue arguments, and RabbitMQ attachesx-deathheaders recording the reason, the origin queue, and how many times it happened. - Apache Kafka has no broker-native DLQ. Kafka only tracks offsets; it has no concept of a “failed” message. The dead-letter topic pattern is something you build in the consumer, in a Kafka Streams topology, or in a Kafka Connect connector — commonly paired with a retry-topic tier before the terminal DLT, as Spring Kafka’s
@RetryableTopicandDeadLetterPublishingRecovererdo. - Azure Service Bus dead-letters automatically once a message’s delivery count exceeds
MaxDeliveryCount(default 10), and also for a handful of system reasons such asTTLExpiredException,HeaderSizeExceeded, andMaxTransferHopCountExceeded, each recorded in the message’sDeadLetterReasonproperty.
For a broader view of how brokers and streaming platforms fit together operationally rather than as a reliability pattern, Apache Kafka Quickstart and RabbitMQ on AWS EKS vs SQS cover the infrastructure side of running these brokers.
Poison Messages
A poison message is one that will never succeed no matter how many times a consumer retries it — a malformed JSON payload, a schema field that a producer renamed, a business rule violation, or a bug that throws on a specific input every single time. That is different from a transient failure, where the message is fine but the environment briefly is not: a downstream timeout, a database connection blip, a rate limit response.
Treating both failure types the same way is the most common DLQ mistake. If you dead-letter on the first failure, you punish transient errors that would have succeeded on retry. If you retry poison messages dozens of times before giving up, you waste compute, delay unrelated messages behind them (on ordered queues and partitions), and flood your logs with the same stack trace.
A few detection signals help separate the two:
- Exception type. Deserialization errors, validation errors, and
ClassCastException-style failures are almost always permanent. Spring Kafka’sDefaultErrorHandlerexplicitly treats certain exceptions as fatal and skips retries for them rather than exhausting the retry budget first. - Repeat count with no variance. RabbitMQ’s
x-deathheader array lets you see exactly how many times a message has been dead-lettered and why; a message with a growing count and an identicalx-first-death-reasonon every cycle is poison, not unlucky. - Consistent failure across replicas. If every consumer instance fails on the same message while succeeding on everything around it, the message itself is the problem, not the infrastructure.
For distinguishing retryable from non-retryable failures at the code level — the same classification a DLQ policy depends on — see Go Error Handling Architecture: Boundaries and Patterns.
Retry vs Discard
The core policy decision behind every DLQ is the retry threshold: how many delivery attempts a message gets before it is quarantined. Get this too low and you dead-letter messages that would have succeeded after a brief downstream hiccup. Get it too high and a poison message sits in the main queue for a long time, consuming worker capacity and — on ordered systems — blocking everything queued behind it.
Current guidance across the major brokers converges on similar numbers:
| Broker | Mechanism | Typical threshold |
|---|---|---|
| Amazon SQS | maxReceiveCount in redrive policy |
3–5 for mixed transient/permanent workloads |
| RabbitMQ (quorum queues) | delivery-limit policy argument |
3–5, tuned per queue |
| Azure Service Bus | MaxDeliveryCount |
Default 10, often reduced for latency-sensitive queues |
| Kafka (via retry topics) | Retry-count header + retry-topic tier | 3–4 retry-topic hops before the terminal DLT |
A practical middle ground many teams land on is: start conservative (2–3 attempts), watch the actual failure mix in production, and raise the threshold only for queues where you can show most failures resolve within a few retries. Pair the retry count with exponential backoff and jitter between attempts so a downstream outage does not turn into a retry storm — the same discipline covered in backoff and circuit-breaker design. A circuit breaker at the integration boundary complements this: it stops sending requests to an unhealthy dependency instead of letting every message in the queue individually discover the outage and dead-letter one by one.
Once a message is in the DLQ, “discard” should still be a deliberate action, not neglect. Set a retention period on the DLQ itself — long enough to investigate (AWS recommends the DLQ retention exceed the source queue’s; a week is a common floor for RabbitMQ DLQs) — and alert on DLQ depth and age so failures get triaged instead of silently expiring. A message that ages out of the DLQ unexamined is a message you decided to lose without deciding to lose it.
Idempotency matters just as much here as it does anywhere else duplicates can occur: a message that gets redriven from a DLQ back to the main queue is, functionally, a duplicate delivery. If your consumer is not safe to run twice on the same message, redriving from a DLQ can create the exact duplicate-side-effect bug you were trying to avoid. See Idempotency in Distributed Systems That Actually Works for the consumer-side patterns that make redrive safe.
Replay Strategies
Getting a message out of the DLQ correctly is its own discipline, separate from getting it in.
- Fix the root cause first. Deploying the consumer fix before replaying is the difference between a clean recovery and re-poisoning the queue with the same failure a second time.
- Redrive deliberately, not automatically. SQS supports a redrive-to-source feature that moves messages back to their original queue (or another destination) on demand; RabbitMQ and Kafka require you to build the equivalent consumer or tooling yourself. Either way, treat replay as an operator-triggered action with a record of what was replayed and when.
- Preserve ordering where it matters. For Kafka, the dead-letter topic should have at least as many partitions as the source topic and should retain the original message key, so that replayed messages land back on the correct partition and preserve per-key ordering.
- Cap replay attempts. A message that fails again after a fix-and-replay cycle is not transient — route it to a permanent archive (a database table, an object-storage bucket) instead of looping it through the DLQ indefinitely. RabbitMQ’s own docs warn that a dead-lettered message can be routed between queues only a limited number of times (16) before further TTL-based dead-lettering is disabled.
- Never let a DLQ dead-letter into itself. If your DLQ has its own
x-dead-letter-exchange(RabbitMQ) or its own redrive policy (SQS) pointed back at the same chain, a replay failure can create an infinite loop. Keep the DLQ’s own dead-letter configuration empty, or point it at a strictly terminal archive. - Alert on volume, not just presence. A single message in a DLQ is a data point; a sudden spike is an incident. Wire DLQ depth and message age into the same alerting pipeline you use for everything else — see Modern Alerting Systems Design for Observability Teams for routing and noise-reduction practices that apply directly to DLQ alerts.
If your workflow involves multi-step, long-running processes rather than single messages, the same dead-letter thinking applies at the workflow layer — a saga’s compensation logic needs the same “quarantine, inspect, decide” discipline when a step fails permanently instead of transiently. And when the events themselves originate from a database write, the transactional outbox pattern already builds dead-letter handling into the relay worker, so the pattern shows up one layer earlier than the broker.
Where DLQs Fit in the Bigger Picture
A dead-letter queue does not make failures go away — it makes them survivable and reviewable instead of silent. It works best alongside retries with backoff for the transient case, idempotent consumers so redrive is safe, and a circuit breaker so a struggling dependency does not flood the main queue (and, eventually, the DLQ) with the same failure thousands of times over. Treat the DLQ threshold, retention, and alerting as first-class configuration decisions, not defaults you leave untouched, and dead letters become a diagnostic tool instead of a place where data quietly disappears.