A dead letter queue is where messages go when your consumer has given up on them, and the patterns that make it useful are all about what happens after that: classifying failures so only the hopeless ones land there, wrapping each message in an envelope with enough context to debug it, isolating DLQs so one bad producer can’t hide another’s failures, and building a redrive path that replays messages deliberately instead of blindly. The pattern that matters most is the one nobody writes down: a DLQ is an operational loop you commit to staffing, not a checkbox you enable. A dead letter queue that nobody drains is just a slower, more expensive way to lose data.
The short answer
Route a message to the DLQ when retrying it can no longer plausibly help — either because you’ve exhausted a bounded retry budget on a transient failure, or because you’ve classified it as deterministic and there’s no point retrying at all. Store it with its full failure context, not the bare payload. Give each consumer its own DLQ so failures stay attributable. Use dead-letter rate to detect new incidents, and depth and age to track recovery and retention risk. And build redrive as a real tool — rate-limited, sampled, and only run after the root cause is fixed.
Classify the failure before you route it
Every DLQ design decision follows from one question: why did this message fail? Teams that skip the classification step end up with a DLQ that mixes a downstream outage’s worth of perfectly good messages together with genuinely corrupt ones, and then they can’t safely replay any of it.
Three classes cover nearly everything I see in production:
Transient failures are the ones that will probably succeed on a retry — a timeout, a 503 from a dependency, a lock contention error, a rate limit. The message is fine; the world was briefly hostile. These deserve the full retry budget with exponential backoff and jitter, and they should only reach the DLQ after that budget is exhausted.
Poison messages are deterministic. A malformed payload, a schema the consumer can’t parse, a foreign key that references a row that will never exist, a field with a value the code has no branch for. These fail identically on attempt one and attempt fifty. Retrying them is pure waste — you burn worker capacity and delay every healthy message behind them, and you buy nothing. Poison messages should go straight to the DLQ on first detection.
Systemic failures look like poison in the small and transient in the large: every message is failing because you just deployed a bug or a downstream service is entirely down. The dangerous property here is that a naive retry-then-dead-letter pipeline will happily shovel your entire working set into the DLQ during a ten-minute outage. That’s the failure mode that turns a brief incident into a day of recovery work.
The routing rule falls straight out of this taxonomy: retry the transient, dead-letter the poison immediately, and stop the line on the systemic. In practice that means the consumer needs to distinguish an exception it can’t parse (poison) from an exception the network threw (transient), and a circuit breaker or a global failure-rate check needs to pause consumption when nearly everything is failing rather than draining the queue into the DLQ.
class Poison(Exception):
"""Deterministic — will never succeed. Do not retry."""
def consume(msg):
try:
payload = schema.parse(msg.body) # bad shape -> Poison
except SchemaError as e:
raise Poison(f"unparseable payload: {e}")
try:
apply(payload)
except (Timeout, ServiceUnavailable, RateLimited):
raise # transient -> retry with backoff
except ValidationError as e:
raise Poison(f"invalid business state: {e}")
def worker_loop(msg):
try:
consume(msg)
msg.ack()
except Poison as e:
dead_letter(msg, e, attempts=msg.attempts, reason="poison")
msg.ack() # don't leave it to redeliver
except Exception as e:
if msg.attempts >= MAX_ATTEMPTS:
dead_letter(msg, e, attempts=msg.attempts, reason="retries_exhausted")
msg.ack()
else:
msg.retry(delay=backoff_with_jitter(msg.attempts))
This is broker-neutral pseudocode: dead_letter must confirm durable storage before ack can release the source message. A crash between those steps can produce duplicate dead letters, so the quarantine store also needs a stable identity. The systemic-failure circuit breaker sits outside this per-message loop.
The important detail is that reason field. It’s the difference between a DLQ you can triage and a pile you have to hand-inspect: “retries_exhausted” during an outage window is a strong candidate for bulk replay, while “poison” almost never is.
The dead-letter envelope: never store the bare payload
The single most common DLQ mistake I find is dead-lettering the original message and nothing else. Six hours later somebody opens the queue, finds a JSON blob, and has no idea which consumer rejected it, why, how many times it tried, or whether it’s safe to replay. The message is technically preserved and practically useless.
Wrap it in an envelope:
def dead_letter(msg, error, attempts, reason):
dlq.put({
"schema_version": 1,
"reason": reason, # poison | retries_exhausted | systemic
"payload": msg.body, # the ORIGINAL, unmodified, for replay
"message_id": msg.id,
"idempotency_key": msg.key, # enforced by the receiving consumer
"source_queue": msg.queue,
"consumer": CONSUMER_NAME,
"consumer_version": BUILD_SHA, # which code rejected it
"attempts": attempts,
"first_seen_at": msg.first_seen_at,
"failed_at": now(),
"error_type": type(error).__name__,
"error": str(error),
"stacktrace": traceback.format_exc(),
"trace_id": current_trace_id(), # jump straight to the logs
})
Two fields do the heavy lifting and are the ones teams leave out. consumer_version tells you whether a fix has already shipped since the message failed — the difference between “replay this now” and “this will fail again the instant you touch it.” And trace_id connects the dead letter back to the actual request trace, which turns debugging from archaeology into a single click. Keep the original payload byte-for-byte separate from the metadata, too; if you flatten your diagnostic fields into the payload, replay quietly sends the wrong message shape back into the main queue.
Isolate DLQs by consumer, not by company
The shared, one-big-DLQ-for-everything design is tempting because it’s one queue to monitor. It fails for a simple reason: it destroys attributability. When the DLQ alarm fires, nobody knows whose it is, so it becomes nobody’s. Meanwhile a steady trickle of expected failures from one low-stakes consumer raises the baseline enough that a genuine spike from a critical consumer disappears into the noise.
Give each consumer (or each queue-consumer pair) its own DLQ. The blast radius of a bad deploy stays contained, each DLQ has an owning team, the alert threshold can be tuned to that consumer’s normal failure rate, and replay targets exactly the messages that belong to the fix you just shipped. In multi-tenant systems, consider going a level further and isolating by tenant as well — one customer sending malformed data shouldn’t be able to fill a DLQ that another customer’s failures need to be visible in. That’s the same isolation logic I apply to worker fleets and failure taxonomies: failures should be contained to the smallest unit that can be reasoned about independently.
Redrive is a tool you build, not a button you press
Redrive — replaying messages from the DLQ back into the main queue — is where the DLQ pattern either pays off or blows up. The blow-up looks like this: the DLQ has 40,000 messages, somebody ships a fix, somebody clicks “redrive all” in the console, and 40,000 messages hit a service sized for normal traffic. Either the fix wasn’t actually the fix and everything re-poisons the DLQ, or the replay itself becomes the second outage.
Replay is a controlled operation with four properties:
- It happens after the root cause is fixed, never before. Blind replay re-poisons by definition — the messages failed for a reason, and if the reason still exists, all you’ve done is take a lap.
- It’s sampled first. Replay 1% or a hundred messages, watch them succeed, then continue. This is the cheapest possible test of “was that actually the fix.”
- It’s rate-limited. Feed messages back at a rate the consumer and its downstream dependencies can absorb on top of live traffic, not instead of it.
- It’s filtered. Replay by
reason,consumer_version, and time window — the messages that failed during Tuesday’s outage, not everything that has ever failed.
def redrive(dlq, *, since, until, reason, limit, rate_per_sec, dry_run=True):
replayed = 0
for env in dlq.scan(since=since, until=until):
if env["reason"] != reason or replayed >= limit:
continue
if dry_run:
log.info("would replay", id=env["message_id"], err=env["error_type"])
else:
# put must confirm durable acceptance before the DLQ copy is deleted.
# The consumer must enforce the key; attaching it alone is not enough.
main_queue.put(env["payload"], key=env["idempotency_key"])
dlq.delete(env)
replayed += 1
sleep(1 / rate_per_sec)
return replayed
This sketch assumes one source destination and one redrive worker; a production adapter needs destination validation, exclusive claims or leases, positive rate/limit validation, and filtering by consumer, version, and incident window. A crash after publishing but before deletion can replay twice. The original copy stays recoverable on a publish failure, and ambiguous publish outcomes must be safe to retry.
Note the dry_run default and the idempotency key. Replay is only safe at all because the consumers are idempotent — a message that partially succeeded before failing will be re-processed, and if that double-charges someone, your DLQ is a liability rather than a safety net. Idempotency is the precondition for this entire pattern; I’ve written the full version of that argument in how to design an idempotent job queue. Native tools have different boundaries: SQS managed redrive supports velocity control but does not filter or modify messages during redrive. The selective replay above requires application tooling rather than assuming a console button implements those filters.
Alert on the rate, page on the ownership
DLQ depth is the metric everyone graphs, but depth alone cannot distinguish a new incident from an old backlog. Two hundred messages from a fixed incident still need an owner and enough retention to recover. Forty new messages in a minute signal an active failure even if the queue is below a depth threshold. Use rate for incident detection and depth plus age for recovery, capacity, and retention alerts.
The three signals worth wiring up:
- Dead-letter rate (messages/minute, and as a fraction of throughput) — the leading indicator of a bad deploy or a downstream failure. This is the one that pages.
- Age of the oldest message — the indicator that the DLQ isn’t being drained, which is an organizational failure rather than a technical one. This is the one that files a ticket.
- A non-zero DLQ that nobody has looked at in N days — because that’s the state the whole pattern is meant to prevent.
Budget retention for time spent retrying plus time needed for investigation and recovery. In SQS standard queues, expiry retains the original enqueue timestamp; FIFO queues reset that timestamp on transfer to the DLQ. With standard queues, a message that spent three days retrying has only one day left under a four-day DLQ retention period. Check the actual broker semantics rather than assuming every DLQ starts a fresh clock. Keep the alerting actionable, with an owner and a response, using the same discipline as keeping monitoring separate from alerting.
Worked example: from consumer-group failure to controlled redrive
Consider a synthetic order-indexing service. order-events feeds the logical consumer group order-indexer, whose replicas update a search index. Its application-managed quarantine is order-indexer-dlq; another consumer’s failures go elsewhere. Here, “consumer group” identifies one independently owned subscription, not a promise that every broker exposes Kafka’s group API. The example values below are illustrative, not measured production results or universal alert thresholds.
Between 13:00 and 13:05 UTC, an indexing dependency returns intermittent 503s. Forty messages exhaust a five-attempt budget and enter the DLQ. Three unrelated messages fail schema validation and are quarantined immediately. If failures become widespread, the group’s circuit breaker pauses consumption instead of continuing to funnel healthy work into quarantine.
Preserve identity, routing, and the incident window
One of the 40 recoverable candidates has this envelope. The payload is shown as a JSON object for readability; the real capture preserves the original serialized payload separately from diagnostic metadata.
{
"schema_version": 1,
"message_id": "evt-1042",
"idempotency_key": "order-index:1042:revision-7",
"source_queue": "order-events",
"consumer": "order-indexer",
"consumer_version": "release-42",
"reason": "retries_exhausted",
"attempts": 5,
"first_seen_at": "2026-09-29T13:00:00Z",
"failed_at": "2026-09-29T13:04:30Z",
"error_type": "ServiceUnavailable",
"error": "index dependency returned 503",
"trace_id": "example-trace-1042",
"payload": {"order_id": "1042", "revision": 7}
}
The quarantine identity is the subscription plus the original message ID: (order-indexer, evt-1042). Redelivery updates that record instead of creating an indistinguishable duplicate. The effect identity is different: order-index:1042:revision-7 follows the business operation through replay, even if the broker assigns a new message ID. The consumer enforces that identity through an idempotent, version-aware index update; a stale revision must not overwrite a newer one. Recording a key in the envelope alone provides no duplicate protection.
Read rate, depth, and age together
Suppose the group handled 1,800 distinct messages in the five-minute interval, of which 43 newly entered quarantine. Count unique failure transitions rather than retry deliveries when calculating this application-level ratio.
| Signal | Example and response |
|---|---|
| New dead-letter rate | 43 / 5 = 8.6 messages per minute. Investigate the incident window and dependency errors. |
| Dead-letter fraction | 43 / 1,800 = 2.39%. Compare with this group’s baseline and business impact. |
| DLQ depth by reason | 40 exhausted retries; 3 poison messages. Select the recoverable cohort and leave poison isolated. |
| Oldest unresolved failure | 15 minutes since failed_at at triage. Track recovery age alongside the broker’s actual expiry deadline. |
| Live queue and dependency health | Backlog stable; 503s stopped. Candidate evidence for a small replay, not permission to replay everything. |
An example policy might page the owning team when the dead-letter fraction exceeds 1% and at least 10 unique messages fail in five minutes. An oldest-unresolved-age threshold of one hour might create a follow-up ticket, with a separate urgent alert before retention expires. Tune both to volume, business impact, and recovery time. A low-volume critical consumer may need a page on its first failure. Keep message IDs and trace IDs in logs; use bounded labels such as consumer and reason for metric series.
Make recovery an observable sequence
- Confirm the dependency has recovered and verify the consumer’s handling of duplicate delivery. A successful health check alone does not validate message processing.
- Dry-run an exact cohort: consumer
order-indexer, failing versionrelease-42, reasonretries_exhausted, andfailed_atin[13:00, 13:05)UTC. The expected selection is 40, not all 43. Unexpected counts stop the run for inspection. - Publish a sample of 10 at one message per second, within measured spare capacity. Keep each DLQ record until the destination confirms durable acceptance. Preserve the business idempotency key and record the replay run ID in transport metadata.
- Observe those 10 through successful processing for an illustrative two-minute window. Stop if any sample message dead-letters again, the dependency returns 503s, or live-work latency breaches its budget. A depth reduction proves handoff, not successful business processing.
- If the sample passes, replay the remaining 30 at at most two per second, continuing the same checks. Track completed effects independently of broker acknowledgements. A duplicate or an obsolete revision may correctly produce no additional write.
- Reconcile all 40 replay candidates to terminal outcomes and retain the three poison messages with their own investigation owner. The final DLQ depth is three only if no other failures arrived; never call the incident resolved merely because a queue became smaller.
The useful artifact at the end is a recovery record: selected cohort, replay run, confirmed outcomes, remaining poison messages, and any duplicate deliveries suppressed. This is what turns schema design, consumer-group isolation, metrics, and alerts into one operating procedure. The surrounding capacity controls belong with worker-fleet design, while the service-side implementation belongs in monitoring and operations work.
Where dead letter queues don’t come for free
Kafka has no native DLQ. It has no per-message acknowledgement or redelivery counter, so “dead letter” means a topic you produce to yourself when your consumer’s error handler gives up, and the retry count has to be tracked in a header you maintain. Partition ordering also means a poison message blocks its partition entirely until you either skip it or route it out — which makes fail-fast poison detection more urgent on Kafka than on SQS, not less.
Ordering-sensitive consumers. If your consumer relies on strict per-key ordering, pulling one message out to the DLQ and letting the next one through has just reordered your stream. For ordered work you usually need to halt that key’s processing rather than skip ahead — which means your DLQ needs to carry the whole key’s backlog, or you need to accept the reordering explicitly. This is a design decision, not an implementation detail, and it’s the one most likely to be discovered in production. Graceful degradation under this kind of pressure is the broader subject of designing for disruption.
When you don’t need this
If your queue processes low-volume, low-stakes work and a lost message costs nothing, the full apparatus is overkill — a bounded retry and a log line may genuinely be enough. If you’re on a managed queue with a built-in DLQ and your failure volume is low enough to inspect by hand, you don’t need custom redrive tooling; you need to actually look at the queue. And if your messages are perfectly reconstructible from an upstream system of record, replay-from-source can be simpler and more honest than maintaining a DLQ at all.
The machinery here earns its keep when messages represent work you cannot recreate, failure is constant enough that manual handling doesn’t scale, and the cost of silently dropping a message is real. That’s most systems doing meaningful background work — but it’s worth checking that it’s yours before building it.
FAQ
What’s the difference between a retry and a dead letter queue? Retries are the trying-again mechanism; the DLQ is the giving-up mechanism. Retries handle transient failures that will plausibly succeed on another attempt. The DLQ catches what’s left — messages that exhausted the retry budget, and poison messages that should never have been retried at all — so they can be inspected and replayed instead of being dropped or cycled forever.
How many retries before dead-lettering? For transient failures, commonly 5–8 attempts with exponential backoff and jitter, tuned to how long your downstream typically takes to recover. For messages you’ve classified as poison, zero — retrying a deterministic failure is pure waste. The number matters less than the classification, which is why the taxonomy comes first.
Should I automatically redrive from the DLQ? Not blindly. Automatic replay of messages whose root cause is still present just re-poisons the queue in a loop. Automated redrive is defensible only for narrowly-scoped, provably transient reasons (say, a dependency-outage window that has been confirmed resolved) and only with rate limiting. The default should be a human deciding, with a dry run first.
Does Kafka have a dead letter queue? Not natively. You implement it as a separate topic your consumer’s error handler produces to, tracking attempt counts in message headers yourself. Because a poison message blocks its partition, detecting and routing poison quickly matters more on Kafka than on brokers with per-message redelivery.
What should the DLQ retention period be? Long enough to cover the applicable message age plus investigation and recovery time. SQS standard queues retain the original enqueue timestamp, so AWS recommends a DLQ retention period longer than the source queue’s; FIFO queues reset the timestamp on transfer. Check your broker’s rules and alert before the actual expiry deadline.
What does DLQ depth tell me? It tells you how much quarantined work remains, not whether failures are still arriving or replay succeeded. Pair depth with the new dead-letter rate, the oldest unresolved failure’s age, the expiry deadline, and confirmed processing outcomes. Split by consumer and reason so one incident does not hide another.
The full set of reliability patterns these fit into — idempotency, retries, backpressure, and observability that stays honest under load — is collected in my guides on reliable systems, queues and observability, and the day-to-day version of it is what I do in monitoring and operations work.