Dead-Letter Exchanges
The problem: a message you can’t process
Section titled “The problem: a message you can’t process”A consumer pulls a message, tries to process it, and fails — the payload is malformed, a downstream service is down, or the record it references doesn’t exist. What now?
If you nack it with requeue, RabbitMQ puts it right back at the front of the queue, you pick it up again immediately, it fails again, and you have a poison message spinning in a tight loop, burning CPU and blocking everything behind it. If you just drop it, you’ve silently lost data. Neither is acceptable.
The answer is to send the failed message somewhere else on purpose. That “somewhere else” is a dead-letter exchange (DLX).
What dead-letters a message
Section titled “What dead-letters a message”A message is dead-lettered — routed to the queue’s configured DLX — when any of these happen:
- It is rejected or nacked with
requeue=false(your consumer says “don’t give this back to me”). - Its message TTL expires while sitting in the queue.
- The queue hits its max-length limit and the message is dropped to make room (overflow).
You attach a DLX to a queue with the x-dead-letter-exchange argument (and optionally x-dead-letter-routing-key to relabel it on the way out).
flowchart LR q["work queue (x-dead-letter-exchange: dlx)"] -->|"nack requeue=false or TTL expiry or overflow"| dlx["DLX (exchange)"] dlx --> dlq["dead-letter queue"] dlq --> ops["ops / inspector / retry logic"]
await channel.assertExchange('dlx', 'fanout', { durable: true });await channel.assertQueue('orders.dead', { durable: true });await channel.bindQueue('orders.dead', 'dlx', '');
// the work queue dead-letters to the DLXawait channel.assertQueue('orders', { durable: true, arguments: { 'x-dead-letter-exchange': 'dlx' },});
// in the consumer, reject a bad message WITHOUT requeue → it dead-letterschannel.nack(msg, false, false);channel.exchange_declare("dlx", exchange_type="fanout", durable=True)channel.queue_declare("orders.dead", durable=True)channel.queue_bind("orders.dead", "dlx")
# the work queue dead-letters to the DLXchannel.queue_declare("orders", durable=True, arguments={"x-dead-letter-exchange": "dlx"})
# reject a bad message WITHOUT requeue → it dead-letterschannel.basic_nack(delivery_tag=method.delivery_tag, requeue=False)ch.ExchangeDeclare("dlx", "fanout", true, false, false, false, nil)ch.QueueDeclare("orders.dead", true, false, false, false, nil)ch.QueueBind("orders.dead", "", "dlx", false, nil)
// the work queue dead-letters to the DLXch.QueueDeclare("orders", true, false, false, false, amqp.Table{ "x-dead-letter-exchange": "dlx",})
// reject a bad message WITHOUT requeue → it dead-lettersd.Nack(false, false)Poison messages: give up gracefully
Section titled “Poison messages: give up gracefully”Not everything should be retried forever. A message that will never succeed — bad schema, a reference that no longer exists — is a poison message. The pattern is: track how many times a message has been tried (a counter in a header, or the x-death records RabbitMQ adds on each dead-letter), and after N attempts, route it to a parking (or “dead”) queue where a human or an alert can look at it. Never let a poison message loop.
TTL + DLX = delayed retry
Section titled “TTL + DLX = delayed retry”Here is the clever trick that dead-lettering unlocks. Sometimes a failure is transient — a downstream service is briefly down — and you want to retry, but not immediately. You want to wait 30 seconds and try again.
Combine a message/queue TTL with a DLX:
- On failure, publish the message to a retry queue that has a TTL of 30s and whose DLX points back at the main exchange.
- The message sits in the retry queue doing nothing for 30 seconds.
- When the TTL expires, RabbitMQ dead-letters it — which routes it back to the main queue for another attempt.
You’ve built a delayed retry with backoff using nothing but queue configuration — no scheduler, no sleep in your consumer. (The Consumer Patterns module builds this into a full retry ladder.)