Dead Letter Queues (DLQ)
IntermediateMessages that cannot be processed land in a DLQ; monitor DLQ depth as an SLA signal and build tooling to replay, inspect, or discard messages after investigation.
Overview
A Dead Letter Queue (DLQ) is a holding area for messages that could not be delivered or processed. In RabbitMQ, messages are dead-lettered (moved via the Dead Letter Exchange) when they are: **rejected** (`basicNack`/`basicReject` with `requeue=false`), **expired** (exceeded `x-message-ttl` or per-message TTL), or **maxlen-exceeded** (queue is full and `x-overflow=reject-publish-dlx`). A DLQ is not automatically created — you must set `x-dead-letter-exchange` on the source queue. DLQ depth is a critical SLA metric: a growing DLQ means your consumer is failing; an empty DLQ is the goal. Build tooling to **inspect** (view message content), **replay** (re-publish to original exchange), and **discard** (drop definitively) DLQ messages.
Configuring a DLQ with x-dead-letter-exchange
Declare a DLX (Dead Letter Exchange) and a DLQ, then bind the DLQ to the DLX. Set `x-dead-letter-exchange` on the source queue to route rejected/expired messages there. Optionally set `x-dead-letter-routing-key` to override the routing key — useful when the DLX is a direct exchange.
@Configuration
class DlqConfig {
// DLX — a direct exchange that routes to the DLQ
@Bean
DirectExchange deadLetterExchange() {
return new DirectExchange("orders.dlx");
}
// DLQ — the queue that holds dead-lettered messages
@Bean
Queue deadLetterQueue() {
return QueueBuilder.durable("orders.dlq").build();
}
@Bean
Binding dlqBinding() {
return BindingBuilder.bind(deadLetterQueue())
.to(deadLetterExchange())
.with("orders.dead"); // routing key
}
// Source queue — messages here get dead-lettered to DLX
@Bean
Queue ordersQueue() {
return QueueBuilder.durable("orders.processing")
.withArgument("x-dead-letter-exchange", "orders.dlx")
.withArgument("x-dead-letter-routing-key", "orders.dead")
.withArgument("x-message-ttl", 300_000) // 5min TTL
.build();
}
}Dead-Lettering in Consumer Code
Send a message to the DLQ by calling `basicNack(tag, false, false)` — the third parameter `requeue=false` tells RabbitMQ not to re-queue the message; the DLX routing takes over. In Spring AMQP, this happens automatically when a `@RabbitListener` throws and the `SimpleRabbitListenerContainerFactory` exhausts its retry attempts (configured via `RetryInterceptorBuilder`).
@RabbitListener(queues = "orders.processing")
public void processOrder(Order order, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
try {
orderService.process(order);
channel.basicAck(tag, false); // success
} catch (PoisonMessageException e) {
// Unrecoverable: send straight to DLQ, never retry
log.error("Poison message: {}", order.getId(), e);
channel.basicNack(tag, false, false); // requeue=false → DLX → DLQ
} catch (TransientException e) {
// Recoverable: requeue for immediate retry
channel.basicNack(tag, false, true); // requeue=true → back in queue
}
}
// Automatic DLQ routing via Spring Retry + RejectAndDontRequeueRecoverer
factory.setAdviceChain(RetryInterceptorBuilder.stateless()
.maxAttempts(3)
.backOffOptions(1_000, 2.0, 10_000)
.recoverer(new RejectAndDontRequeueRecoverer()) // → DLX after 3 attempts
.build());Monitoring and Replaying DLQ Messages
A growing DLQ is a P1 incident signal. Alert on `rabbitmq_queue_messages{queue="orders.dlq"} > 0`. For replay, re-publish DLQ messages to the original exchange. The `x-death` header on dead-lettered messages contains the reason, count, and queue name — invaluable for debugging. Build a replay endpoint or use the RabbitMQ Management UI.
// Prometheus alert — DLQ not empty
- alert: RabbitMQDlqNonEmpty
expr: rabbitmq_queue_messages{queue=~".*dlq"} > 0
for: 1m
annotations:
severity: warning
summary: "RabbitMQ DLQ has {{ $value }} messages — investigate consumer errors"
// Replay: consume from DLQ and re-publish to original exchange
@PostMapping("/admin/dlq/replay")
void replayDlq(@RequestParam int count) {
for (int i = 0; i < count; i++) {
Message msg = rabbitTemplate.receive("orders.dlq", 1_000);
if (msg == null) break;
Map<?,?> xDeath = (Map<?,?>) msg.getMessageProperties()
.getHeaders().get("x-death");
String originalExchange = (String) ((List<?>) xDeath).get(0) ...;
// Re-publish to the original exchange
rabbitTemplate.send("orders.topic", "order.created", msg);
}
}Key Points to Remember
- 1Messages are dead-lettered on: basicNack(requeue=false), TTL expiry, or queue length overflow
- 2DLX + DLQ must be explicitly configured — set x-dead-letter-exchange on the source queue
- 3x-death header on dead-lettered messages contains reason, count, and origin queue
- 4DLQ depth is a critical SLA metric — alert immediately when DLQ becomes non-empty
- 5RejectAndDontRequeueRecoverer + Spring Retry auto-routes to DLQ after exhausting retries
- 6Build replay tooling: re-publish DLQ messages to the original exchange for re-processing
Interview Questions
Sign in to ask AriaWhat are the three reasons a message gets dead-lettered in RabbitMQ?
What is the difference between requeue=true and requeue=false in basicNack?
How would you replay messages from a DLQ back to the original queue?
What information does the x-death header contain and how would you use it?
How would you distinguish a transient failure (should retry) from a poison message (should DLQ immediately)?
Ask Aria about Dead Letter Queues (DLQ)
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.