Read time: ~

Dead Letter Exchanges, DLQ, Delay and Parking-Lot

Route failed and expired messages to a dead-letter exchange, build TTL-based delayed retry, and use a parking-lot queue for messages that keep failing.

Prerequisite:Retry, Error Handling and BackoffYou’ll need: A broker running locally and a Spring AMQP consumer.

A dead-letter exchange (DLX) is where messages go when they cannot be processed. It is the safety net that turns “lost forever” or “looping forever” into “set aside for inspection.” This module is the implementation reference; the operational triage view lives in the Poison Messages and DLQ playbook.


What you’ll be able to do after this module

  • Configure a dead-letter exchange and queue for a work queue.
  • Explain the three triggers that dead-letter a message.
  • Build a TTL-based delayed retry queue.
  • Use a parking-lot queue for messages that exhaust retries.

1. What dead-lettering is

A message is dead-lettered (routed to a queue’s configured DLX) when one of three things happens:

  • It is rejected or nacked with requeue = false.
  • Its TTL expires (per-message or per-queue, from Queues and Messages).
  • The queue exceeds its length limit with drop-head overflow.
flowchart LR
    P["Producer"]
    MXorders.exchange
    MQ["orders.queue<br/>x-dead-letter-exchange = orders.dlx"]
    DLXorders.dlx
    DLQ["orders.dlq"]
    C["Consumer"]
    P --> MX --> MQ --> C
    MQ -.->|"rejected / expired / overflow"| DLX --> DLQ

The DLX is just an ordinary exchange, and the DLQ is just an ordinary queue bound to it. What makes them special is the x-dead-letter-exchange argument on the primary queue.


2. Configure a DLX and DLQ

@Configuration
public class DlxConfig {

    @Bean
    DirectExchange ordersDlx() {
        return new DirectExchange("orders.dlx");
    }

    @Bean
    Queue ordersDlq() {
        return QueueBuilder.durable("orders.dlq").build();
    }

    @Bean
    Queue ordersQueue() {
        return QueueBuilder.durable("orders.queue")
                .deadLetterExchange("orders.dlx")
                .deadLetterRoutingKey("orders.dead")
                .build();
    }

    @Bean
    Binding dlqBinding(Queue ordersDlq, DirectExchange ordersDlx) {
        return BindingBuilder.bind(ordersDlq).to(ordersDlx).with("orders.dead");
    }
}

Any message rejected without requeue from orders.queue now lands in orders.dlq for inspection instead of vanishing.


3. The x-death header

When a message is dead-lettered, RabbitMQ records the history in an x-death header: the original queue, the reason (rejected, expired, maxlen), a count, and timestamps. Read it in a consumer to decide what to do:

@RabbitListener(queues = "orders.dlq")
public void inspect(Message failed) {
    Map<String, Object> death = (Map<String, Object>)
        ((List<?>) failed.getMessageProperties().getHeader("x-death")).get(0);
    long count = (Long) death.get("count");
    // count tells you how many times this message has been dead-lettered
}

The count is the key to delayed retry and parking-lot decisions below.


4. Delayed retry with TTL

Immediate retry does not help a dependency that needs a few seconds to recover. A common pattern is a delay queue: dead-letter the message into a waiting queue that has a TTL and no consumer, so when the TTL expires the message dead-letters back to the main queue for another attempt.

flowchart LR
    MQ["orders.queue"]
    WAIT["orders.retry.queue<br/>TTL = 30s, DLX back to main"]
    MQ -.->|"reject (transient)"| WAIT
    WAIT -.->|"TTL expires"| MQ
@Bean
Queue ordersRetryQueue() {
    return QueueBuilder.durable("orders.retry.queue")
            .ttl(30_000)                              // wait 30s
            .deadLetterExchange("orders.exchange")    // then back to main
            .deadLetterRoutingKey("order.created")
            .build();
}

This gives you backoff without blocking a consumer thread, unlike the in-memory retry from the previous module. Chain several delay queues with increasing TTLs for staged backoff.


5. The parking-lot pattern

Some messages fail every time (a genuine poison message). After a bounded number of retries, move them to a parking-lot queue: a terminal queue with no automatic reprocessing, monitored by humans. Use the x-deathcount to decide when a message has retried enough.

flowchart LR
    MQ["orders.queue"]
    RETRY["orders.retry.queue"]
    PARK["orders.parking-lot.queue"]
    MQ -.->|"transient fail"| RETRY -.-> MQ
    MQ -.->|"count exceeds limit"| PARK
    PARK --> Human["Manual review / replay"]

An operator inspects the parking lot, fixes the root cause (a bad deploy, a schema mismatch), and replays messages back to the main exchange once it is safe. Reprocessing tooling and triage steps are covered in the Poison Messages and DLQ playbook.

Caution: Always alert on parking-lot and DLQ depth. A silently growing DLQ means messages are failing and no one knows. Monitoring is covered in the Observability module.


Checkpoint

You should now be able to:

  • Configure a DLX and DLQ for a work queue.
  • Name the three triggers that dead-letter a message.
  • Read the x-death header to count how often a message has failed.
  • Build a TTL-based delayed retry queue and a parking-lot for poison messages.