RabbitMQ dead-lettering, TTL and counted retries: where do failed jobs go? Intermediate
Estimated reading time: 7 min read
Read first
The problem
Section titled “The problem”A consumer sends an email, the provider returns an error, and the consumer nacks with requeue=true. RabbitMQ redelivers immediately and the error happens again. Within seconds, one job eats most of the CPU and logs, and valid jobs slow down.
Switch to requeue=false and the loop stops, but if the queue has no dead-letter exchange (DLX), the message is dropped for good. Both approaches lack a policy: which errors to retry, how long, how many times, and where to store the job when retries run out.
Overview: why a message leaves a queue
Section titled “Overview: why a message leaves a queue”A DLX is a redirection mechanism: when a message leaves a queue for one of the four reasons below, RabbitMQ republishes it to the DLX. It does not decide whether to retry.
reason |
When it happens | Notes |
|---|---|---|
rejected |
basic.reject or basic.nack with requeue=false |
Initiated by the consumer |
expired |
Message TTL expired | Quorum and classic handle it when the message reaches the head of the queue |
maxlen |
The queue exceeds its length limit | With overflow drop-head (the oldest message at the head) or reject-publish-dlx (the rejected publish); reject-publish does not dead-letter |
delivery_limit |
A quorum queue redelivers beyond x-delivery-limit |
Default 20 since RabbitMQ 4.0 |
When a whole queue expires (queue TTL), its messages are not dead-lettered.
bad-jobThe main queue delivers the message to the consumer; the message is now unacked.
Declaring it correctly
Section titled “Declaring it correctly”The docs recommend configuring with a policy instead of hardcoding arguments (if both set a DLX, the argument wins).
# Policy for the main queue: DLX + routing keyrabbitmqctl set_policy dlx-main '^main\.' \ '{"dead-letter-exchange":"dlx","dead-letter-routing-key":"dead"}' \ --apply-to queuesKey names checked against the documentation:
| Purpose | Policy key | Queue argument |
|---|---|---|
| DLX | dead-letter-exchange |
x-dead-letter-exchange |
| Routing key when dead-lettering | dead-letter-routing-key |
x-dead-letter-routing-key |
| TTL for every message in the queue | message-ttl (ms) |
x-message-ttl |
| TTL for a single message | expiration property (ms string) |
|
| Delete an unused queue | expires |
x-expires |
| Dead-letter strategy (quorum) | dead-letter-strategy |
x-dead-letter-strategy |
| Redelivery limit (quorum) | delivery-limit |
x-delivery-limit |
If both a queue TTL and a message TTL are set, the lower value applies. x-expires is the TTL of the whole queue when unused, not a message TTL; putting it in the wrong place deletes the topology.
TTL is not a precise clock
Section titled “TTL is not a precise clock”An expired message is never delivered to a consumer, but the broker only dead-letters it when it reaches the head of the queue. An expired message behind an unexpired one can sit there until its turn. So “retry after exactly 10 seconds” is only an approximation.
Design consequence: use one retry queue per delay level (e.g. 10s, 1m, 10m); do not mix different TTLs in the same queue.
Counted retries
Section titled “Counted retries”The model: the main queue has a DLX pointing to the retry queue; the retry queue has a TTL and a DLX pointing back to main; the dead queue is the end point.
jobAttempt 1: the consumer receives the job and the provider fails with 503. There is no x-death yet, so the retry count is 0.
The counter comes from the x-death header. Each entry contains queue, reason, count, exchange, routing-keys, time; count is the number of times the message was dead-lettered from that queue for that reason. The consumer reads the count of the (queue=retry, reason=expired) entry:
// Pseudocode: number of retries so farconst deaths = msg.properties.headers?.['x-death'] ?? [];const retries = deaths.find(d => d.queue === 'retry' && d.reason === 'expired')?.count ?? 0;if (retries >= 3) { publishToDead(msg); channel.ack(msg); } // out of retrieselse channel.nack(msg, false, false); // to retry.queueWhy this loop is not cut: RabbitMQ detects dead-letter cycles and drops a message only if the cycle contains no rejected step. Here the consumer nacks each round, so the cycle is valid.
The model above is based on the documentation; the repo has no runnable demo for the retry loop yet. Only the nack-to-dead-queue part below has been run.
Classify errors before retrying:
| Error type | Action |
|---|---|
| Transient (503, network timeout) | Nack, go through the counted retry loop |
| Permanent (payload with the wrong schema) | Publish straight to the dead queue, no retry |
| Business (“resource already deleted”) | Ack and record it per a separate policy |
Safe dead-lettering: at-least-once
Section titled “Safe dead-lettering: at-least-once”By default, dead-lettering is at-most-once: it uses no confirms, so messages can be lost if the target is unavailable. Quorum queues have an at-least-once mode when all of the following hold (checked against the quorum queues docs):
- The source queue is a quorum queue.
- Policy
dead-letter-strategy = at-least-once(defaultat-most-once). overflow = reject-publish(defaultdrop-head; withdrop-headthis mode falls back to at-most-once).dead-letter-exchangeis set; the routing key is optional.- The
stream_queuefeature flag is enabled (check withrabbitmqctl list_feature_flags). - Setting
max-lengthormax-length-bytesis recommended.
rabbitmqctl set_policy dlx-safe '^orders\.' \ '{"dead-letter-exchange":"dlx","dead-letter-strategy":"at-least-once","overflow":"reject-publish","max-length":100000}' \ --apply-to quorum_queuesIn exchange, the source queue holds messages until the target confirms, costing extra resources. You still need to monitor whether the dead queue keeps growing.
Poison messages and x-delivery-limit
Section titled “Poison messages and x-delivery-limit”Since RabbitMQ 4.0, quorum queues have x-delivery-limit with a default of 20. A message redelivered more than 20 times (consumer died or requeue=true) is dead-lettered with reason delivery_limit, or dropped if there is no DLX.
Setting -1 disables the limit, but the docs do not recommend it. Since 4.3 the limit is compared with delivery-count instead of acquired-count.
This is the last safety net for requeue=true loops, not a replacement for counted retries: 20 back-to-back redeliveries are still 20 hot loops.
Demo that was run
Section titled “Demo that was run”docker compose -f examples/docker-compose.yml up -d rabbitmqnpx --prefix examples ts-node examples/src/rabbitmq-dlx/dlx-demo.tsOutput on rabbitmq:4-management-alpine: dead queue: bad-job and x-first-death-reason: rejected. The demo only verifies the nack, DLX, dead queue path. It does not test TTL, quorum queues, at-least-once or a broker restart.
Try changing the dead-letter routing key to a key with no binding: the dead queue stays empty. The real topology decides where messages go, not the name “DLX”.
Real-world example
Section titled “Real-world example”When to use / not use
Section titled “When to use / not use”- Use DLX + retry queues: transient errors that need backoff, without a consumer
sleepholding an unacked delivery. - Go straight to the dead queue: permanent errors.
- Do not use TTL for tight timing SLAs: expiry is only approximate; use a scheduler.
- Need large-scale topic-based retries and long-term replay: consider streams or Kafka (see Kafka vs. RabbitMQ).
Production checklist
Section titled “Production checklist”- Every job queue has a DLX, and the dead queue has an owner, retention and a runbook.
- Test bindings with a real message, not just by reading config.
- Important quorum queues:
at-least-once+reject-publish+max-length. - Do not use
requeue=trueas a retry strategy. - Counted retries via
x-deathor an application header, with a threshold and an end point. - One retry queue per delay level.
- Replays keep the original event ID; consumers are idempotent.
- Monitoring: dead queue depth and age,
delivery_limitrate.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) Does a nack with requeue=true reach the DLX? (2) Why is the main, retry, main loop not cut by cycle detection? (3) What does drop-head overflow do to at-least-once?