A practical guide to dead-letter queues, the pattern that isolates poison messages so one bad payload can't stall your whole pipeline.
Every queue-based system eventually meets a message it cannot process. The JSON is malformed, a referenced record was deleted, a downstream API returns a permanent 400, or the consumer hits a bug on one specific payload. Without a plan, that single message becomes a wall. It gets picked up, fails, returns to the queue, gets picked up again, and fails again. Meanwhile everything behind it waits. This is the problem a dead-letter queue solves.
A dead-letter queue (DLQ) is a separate queue where messages land after they repeatedly fail to process on the main queue. It is not a special technology. It is an ordinary queue with a specific job: to hold the messages your system gave up on, so they can be inspected and dealt with later instead of being lost or retried forever.
The value is separation. Your primary queue stays reserved for messages that have a reasonable chance of succeeding. The problem cases move somewhere else where they can't do damage. DLQs are a core building block of event-driven architecture, because async systems have no caller waiting on the line to notice a failure and react. The queue has to handle failure on its own.
Three things go wrong without a DLQ.
Poison messages block the queue: A message that always fails but keeps returning to the front of the queue can stall processing for everything behind it, especially with strict ordering. One bad payload becomes a full outage.
Infinite retry loops burn resources: A consumer that retries a permanently broken message forever wastes compute, floods logs, and can hammer a downstream service into further trouble. Retries should be bounded.
Dropped messages destroy evidence: The lazy fix is to catch the error and discard the message. Now the failure is invisible. You have no record of what broke, no payload to reproduce it, and no way to recover the data. A DLQ preserves the message so you can find out what happened.
A message moves to the DLQ when one of these conditions is met:
The pattern is retry a bounded number of times, back off between attempts, then dead-letter. Exponential backoff spaces retries out so a transient problem, a brief network blip or a downstream restart, has time to clear. First retry after 1 second, then 2, then 4, then 8. If the message still fails after the last attempt, it goes to the DLQ rather than looping again.
The distinction that matters is transient versus permanent failure. Backoff and retry are for transient errors that might resolve on their own. A malformed payload will never parse no matter how long you wait, so for known-permanent errors it is often better to dead-letter immediately rather than waste the retry budget. This connects to broader message queue patterns around delivery guarantees and idempotency.
Amazon SQS uses a redrive policy. You attach the DLQ to the source queue and set maxReceiveCount. Once a message has been received that many times without a successful delete, SQS moves it automatically.
{
"RedrivePolicy": {
"deadLetterTargetArn": "arn:aws:sqs:us-east-1:111122223333:orders-dlq",
"maxReceiveCount": 5
}
}
RabbitMQ uses a dead-letter exchange (DLX). You declare a queue with a DLX, and rejected, expired, or overflowed messages are republished there.
{
"x-dead-letter-exchange": "dlx.orders",
"x-dead-letter-routing-key": "orders.failed",
"x-message-ttl": 60000
}
Kafka has no built-in DLQ, so the convention is an error topic. The consumer catches a processing failure and produces the original message, plus failure metadata, to a dedicated orders.DLT topic. Frameworks like Spring Kafka wire this up with a DeadLetterPublishingRecoverer after a configured number of retries.
A DLQ is a starting point, not a graveyard. The workflow is alert, inspect, fix, redrive.
Alert: any message arriving in the DLQ should trigger a notification. A non-empty DLQ means something is failing that you have not handled.
Inspect: read the payload and the attached error to understand the cause. Is it one poison message or a systemic bug hitting many?
Fix: correct the root cause, whether that is a consumer deploy, a downstream fix, or a data correction.
Redrive or replay: once fixed, move the messages back to the main queue for reprocessing. SQS has a native redrive action; RabbitMQ and Kafka usually need a small script or a shovel to move messages back.
Monitor DLQ depth: treat the number of messages in the DLQ as a first-class metric. It should normally be zero.
Include failure metadata: attach the exception, a timestamp, the retry count, and the source queue to each dead-lettered message. Debugging a bare payload with no context is slow.
Set alarms, not dashboards: an alarm on DLQ depth greater than zero (or above a small threshold) reaches you. A dashboard you have to remember to check does not.
Give the DLQ its own retention and access: keep messages long enough to investigate, and make sure the team can actually read and redrive them.
Add a DLQ to any queue whose messages matter, from day one. It costs almost nothing to configure and it is the difference between a contained failure and a stalled pipeline you discover from customer complaints. Set maxReceiveCount to a small number like 5, attach the DLQ, alarm on depth greater than zero, and make sure failure metadata travels with the message. Then decide deliberately which errors deserve retries and which should be dead-lettered on the first failure. The DLQ is not where messages go to die. It is where they wait for you to notice, and that visibility is the whole point.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A practical guide to publish-subscribe messaging, covering topics, fan-out, delivery guarantees, durable subscriptions, and the real systems that power it.
A practical guide to the signals that justify a message queue, the costs it adds, and a checklist for deciding.
Explore more articles in this category
A practitioner's tour of where WebAssembly earns its keep in 2026, from browser apps to edge compute, plus the places it still doesn't fit.
A practical look at why Go usually outruns Python at runtime, where Python holds its own, and how to pick per workload.
A grounded look at WebAssembly, the portable binary format that runs code at near-native speed inside a secure sandbox.
Evergreen posts worth revisiting.