Engineering Notes
Home/Distributed Systems/Message Brokers/Dead Letter Queues
Distributed Systems · Message Brokers

Dead Letter Queues

Some messages will never succeed. Without a dead letter queue they loop forever or block a whole partition. Classify failures as transient or permanent, build a retry topology with delays, and operate DLQs so they never become silent data loss.

🕸️ Distributed Systems● Intermediate⏱ 4 min read▶ 2 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • The poison-message problem in queues and partitions
  • Transient vs permanent failures
  • DLQ mechanics in RabbitMQ, SQS and Kafka
  • Retry topologies, what to store, alerting and safe redrive

1The Poison Message Problem

Some messages will never succeed: malformed JSON, a schema the consumer doesn't understand, a reference to a deleted customer, a bug triggered by one specific payload. Under at-least-once delivery, such a poison message is redelivered forever:

  • in a queue with requeue-on-failure, it spins in a hot loop, burning CPU and filling logs
  • in a Kafka partition, it blocks every message behind it (cumulative commits, 08-acknowledgements_and_message_ordering/ §6)

A dead letter queue (DLQ) is where messages go after they've failed enough times, so the main flow keeps moving and a human (or tool) can inspect them.

2Transient vs Permanent Failures

The first decision on every failure: retry or dead-letter?

FailureExampleAction
transienttimeout, 503, DB failover, rate limitedretry with backoff
permanentinvalid payload, unknown schema version, 400/404/422 from a dependency, business rule violationdead-letter immediately (retrying can't help)
unknownunexpected exceptionretry a few times, then dead-letter

Classify errors explicitly in code (class PermanentError extends Error) instead of retrying everything N times.

3Broker Mechanics

RabbitMQ — a queue declares a dead-letter exchange (x-dead-letter-exchange). A message is dead-lettered when it's rejected/nacked with requeue=false, its TTL expires, or the queue's length limit is hit. The broker adds an x-death header recording the reason, original queue and count (02-rabbitmq/ covers the setup). Delayed retries are built from TTL'd "wait" queues that dead-letter back to the main exchange.

SQS — a redrive policy on the source queue: after maxReceiveCount receives without deletion, SQS moves the message to the DLQ automatically. SQS also offers DLQ redrive to move messages back to the source after a fix.

Kafka — no built-in DLQ; consumers implement it: on permanent failure (or after N retries), produce the record to <topic>.DLT with headers (original topic, partition, offset, exception class, message, stack, attempt count), then commit the original offset. Spring Kafka, Kafka Connect (errors.deadletterqueue.topic.name) and many frameworks provide this.

4Retry Topology

text
main ──fail──► retry-1 (wait 10 s) ──fail──► retry-2 (wait 1 min) ──fail──► retry-3 (wait 10 min) ──fail──► DLQ
  ▲                │                               │                               │
  └────── success after wait, re-consumed ─────────┴───────────────────────────────┘

Separate retry stages keep the main queue/partition flowing while failed messages wait. Remember the ordering trade-off: a message that went to retry is now behind later messages for the same key.

5What to Store With a Dead Letter

A DLQ entry is only useful if someone can act on it:

  • the original payload, unchanged
  • original topic/queue, partition, offset / message id
  • error class, message and stack trace; the consumer version/commit hash
  • attempt count and timestamps (first failure, last failure)
  • correlation / trace id to find the related logs
💡 Example: a useful DLQ record
json
{
  "payload": "{\"orderId\":\"o-17\",\"addr\":null}",
  "source": { "topic": "orders", "partition": 3, "offset": 88123 },
  "error": { "class": "ValidationError", "message": "addr is required" },
  "consumer": "shipping@3f9a2c1",
  "attempts": 4,
  "firstFailure": "2026-10-20T09:12:03Z",
  "lastFailure": "2026-10-20T09:24:41Z",
  "traceId": "4bf92f35…"
}

6Operating a DLQ

  • Alert on DLQ depth > 0 (or a rate) — a DLQ nobody watches is a silent data-loss mechanism.
  • Triage: is it a bug (fix and redrive), bad data (correct and redrive, or discard with a record of why), or a dependency outage (redrive later)?
  • Redrive must be idempotent-safe: handlers must tolerate the message arriving late and possibly after newer events (08-…/ §5 version checks).
  • Retention: DLQ messages must outlive your triage time (days, not hours).
Under the HoodHow RabbitMQ Dead-Lettering Rewrites a Message▾

When a message is dead-lettered, RabbitMQ republishes it internally to the configured DLX with:

  • the routing key replaced by x-dead-letter-routing-key if set, otherwise the original routing key
  • an x-death header: an array of entries { queue, reason (rejected|expired|maxlen|delivery_limit), count, exchange, routing-keys, time }; the count increments if it dies again in the same queue for the same reason
  • x-first-death-queue/reason/exchange headers that never change after the first death

Quorum queues add a x-delivery-limit (per-message redelivery counter maintained by the queue itself): exceeding it dead-letters the message with reason delivery_limit — a broker-enforced poison-message guard that doesn't depend on consumer code. Note that dead-lettering in classic queues is at-most-once by default (the source queue drops the message once it hands it to the DLX); quorum queues support at-least-once dead-lettering (dead-letter-strategy=at-least-once) which keeps the message until the DLX target confirms.

Messages that fail transiently go through delayed retry stages and back to the main consumer, while permanent failures and exhausted retries go to a dead-letter queue with error metadata, where they are alerted on, triaged and redriven
Messages that fail transiently go through delayed retry stages and back to the main consumer, while permanent failures and exhausted retries go to a dead-letter queue with error metadata, where they are alerted on, triaged and redriven

⚠️ Common Pitfalls

  1. Infinite requeue — nack(requeue=true) on a poison message is a hot loop.
  2. Retrying permanent errors — wastes capacity and delays the alert.
  3. DLQ without alerting — quiet data loss.
  4. Dead-lettering without context — no error, no offset, no trace id → impossible to triage.
  5. Redrive that isn't idempotent — reprocessing creates duplicates or overwrites newer state.
  6. One DLQ for everything — mixing unrelated failures makes triage and redrive dangerous.

📋 Quick Reference

BrokerDLQ mechanism
RabbitMQx-dead-letter-exchange, nack(requeue=false), x-delivery-limit (quorum)
SQSredrive policy maxReceiveCount → DLQ; DLQ redrive back
Kafkaapp-level: produce to <topic>.DLT with error headers, then commit
Allclassify errors · bounded retries with backoff · alert on depth · idempotent redrive

🧠 Key takeaways

  • Poison messages loop forever (requeue) or block a partition (Kafka); DLQs keep the main flow moving.
  • Classify failures: transient → retry with backoff; permanent → dead-letter immediately.
  • Use delayed retry stages (accept the ordering trade-off), and store payload + source + error + attempts + trace ID.
  • Alert on DLQ depth, triage, and redrive with idempotent, version-aware handlers.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is a dead-letter queue and why use one?core▾

A queue that receives messages that couldn't be processed after a number of attempts (or expired, or were rejected). It stops poison messages from blocking the main queue or retrying forever, while preserving them for investigation and replay.

Follow-ups interviewers ask
  • Which brokers support it natively? SQS (redrive policy), RabbitMQ (dead-letter exchanges), Azure Service Bus; Kafka via a DLQ topic in the consumer.
Q2When should a message go to the DLQ?core▾

After N failed attempts for transient errors, or immediately for permanent errors (invalid payload, unknown schema version, business rule violation) where retrying can't help.

Follow-ups interviewers ask
  • How do you choose N? Enough to ride out short outages with backoff, e.g. 3–5 attempts over several minutes.
Q3What do you do with messages in the DLQ?senior▾

Alert when it receives messages, inspect the error metadata, fix the bug or data, and replay messages back to the main queue (redrive) with tooling. Set retention long enough to act.

Follow-ups interviewers ask
  • What's the danger of replay? Reprocessing in a different order or after newer events; consumers must be idempotent and order-aware.
Q4What metadata should a dead-lettered message carry?core▾

Original payload and headers, the error message/stack, attempt count, original queue/topic, partition and offset, and timestamps of first and last failure.

Follow-ups interviewers ask
  • Why the original offset? To trace the message and understand ordering impact.
Q5Is a DLQ a replacement for monitoring?core▾

No. A DLQ silently filling up is a hidden outage. Monitor DLQ depth and age with alerts, and treat DLQ messages as incidents or tickets.

Follow-ups interviewers ask
  • What's a good alert? DLQ depth > 0 for critical flows.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. A malformed JSON message should be…
Retrying can't fix a permanent error.
2. What does a poison message do in a Kafka partition without a DLQ?
Commits are cumulative.
3. Why alert on DLQ depth > 0?
Someone must triage.
4. What's the cost of moving a message to a retry topic?
An explicit ordering trade-off.