RabbitMQ Reliability, Dead-Lettering and TTL

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Publishing to RabbitMQ and Consuming from RabbitMQ covered confirms and acknowledgements as individual mechanisms; RabbitMQ Queue Types previewed a quorum queue’s delivery limit and at-least-once dead-lettering in passing. This page is where reliability becomes the subject itself: how a client survives a dropped connection, how confirms and acks together add up to at-least-once delivery, what happens to a message the broker gives up on (dead-lettering, in full), how long a message or a queue is allowed to hold on to it (TTL and length limits), how priority and delay change delivery order and timing, and how those pieces combine into a working retry topology.

Connection recovery and heartbeats

A TCP connection can be lost silently — no FIN, no RST, just packets that stop arriving, which the operating system itself can take a long time to notice (on Linux, roughly eleven minutes with default settings). RabbitMQ’s AMQP 0-9-1 heartbeat feature exists specifically to detect that faster at the application layer: client and broker each negotiate a heartbeat timeout at connection time (the greater of the two configured values when either side requests 0, defaulting to 60 seconds), send an empty heartbeat frame at half that interval whenever no other traffic has gone by, and consider the connection dead once more than two consecutive intervals pass with neither a heartbeat frame nor any other data received.

Most client libraries, including the RabbitMQ Java client, layer automatic connection recovery on top of that detection: once a connection is confirmed lost, the client reconnects, and re-declares the topology it remembers having declared (exchanges, queues, bindings) along with re-registering its consumers — transparently enough that application code often does not need its own reconnect loop. What automatic recovery does not do on its own is resume in-flight work exactly where it left off: any channel open on the failed connection is gone and has to be re-opened, and any unacknowledged deliveries that were in flight at the moment of failure are redelivered fresh once the consumer reconnects, which is exactly why the next section’s acknowledgement discipline matters as much as recovery itself.

ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
factory.setAutomaticRecoveryEnabled(true);      // the Java client's default -- shown here for clarity
factory.setRequestedHeartbeat(60);               // seconds; negotiated with the broker's own configured value
factory.setNetworkRecoveryInterval(5_000);       // milliseconds between reconnect attempts after a failure

Connection connection = factory.newConnection();

Confirms and manual acks: the at-least-once pair

At-least-once delivery is not one mechanism — it is two, one on each side of the broker, and both have to be in place for the guarantee to hold end to end. On the publishing side, Publishing to RabbitMQ's publisher confirms tell a publisher whether the broker actually accepted a message, so a publisher that never sees a confirm knows to retry rather than assume success. On the consuming side, Consuming from RabbitMQ's manual acknowledgement (basic.ack/ basic.nack/basic.reject) tells the broker whether a consumer actually finished processing, so a message stays redeliverable until it does. Drop either half — automatic acknowledgement on the consumer side, or publishing without confirms on the producer side — and the guarantee degrades to at-most-once for that half of the pipeline: a message can be lost during publish, or lost if a consumer crashes mid-processing after auto-acking, even while the other half still behaves correctly.

Dead-letter exchanges and the four dead-letter reasons

A dead-letter exchange (DLX) is an ordinary exchange a queue is configured to republish certain messages to instead of discarding them outright, via the x-dead-letter-exchange queue argument (and, optionally, x-dead-letter-routing-key to override the routing key used on republish — the message’s original routing key is reused by default). Exactly four reasons cause a message to be dead-lettered, each recorded as a standardized reason value in the message’s death history (the x-death header on AMQP 0-9-1, or the x-opt-deaths annotation on AMQP 1.0 — both carrying the source queue, reason, count, and timestamp for every dead-lettering event a message has been through):

Reason Trigger

rejected

Negatively acknowledged with basic.nack/basic.reject and requeue=false.

expired

The message’s per-message or per-queue TTL elapsed (below).

maxlen

Dropped to enforce a queue length limit (below), under an overflow setting that dead-letters instead of just dropping.

delivery_limit

A quorum queue’s per-message delivery limit was exceeded —  RabbitMQ Queue Types covers this as the poison message case.

Map<String, Object> arguments = Map.of(
        "x-dead-letter-exchange", "orders.dlx",
        "x-dead-letter-routing-key", "orders.failed");

channel.queueDeclare("orders", true, false, false, arguments);

At-least-once versus at-most-once dead-lettering

Ordinary dead-lettering is, itself, only at-most-once: the broker removes the message from the source queue and republishes it to the DLX without an internally confirmed hand-off between the two, so a broker crash landing exactly between those two steps loses the message. Quorum queues can close that gap: setting a source queue’s x-dead-letter-strategy to at-least-once (together with x-overflow set to reject-publish and the stream_queue feature flag enabled) makes the source queue keep its own copy of a dead-lettered message, internally confirmed by publisher-confirms-like bookkeeping against the target queue, until that target queue has actually stored it — at the cost of the source queue briefly holding two copies of the same message. Classic queues and streams do not support this stronger mode at all; only a quorum queue’s own dead-lettering can be upgraded to at-least-once.

Per-queue and per-message TTL

A message’s time-to-live can be set two ways, and they compose rather than override each other outright: a per-queue TTL (x-message-ttl, milliseconds, applied via policy or at declaration) bounds every message in that queue uniformly, and a per-message TTL (the expiration property, set by the publisher per message) bounds just that one message. When both are set on the same message, the lower of the two wins.

TTL expiry is checked lazily, only at the head of the queue — an expired message sitting behind still-unexpired ones is not discarded the instant it expires, only once it would actually be the next one delivered (or, for a classic queue, also when certain policy changes touch the queue). A queue can therefore briefly report more ready messages than are actually still live, and a long-expired message parked behind a slow-moving head can sit around far longer than its TTL would suggest.

// per-queue TTL: every message in this queue expires after one hour, unless it expires sooner below
Map<String, Object> queueArguments = Map.of("x-message-ttl", 3_600_000);
channel.queueDeclare("orders", true, false, false, queueArguments);

// per-message TTL: this specific message expires after 30 seconds instead
AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder()
        .expiration("30000")
        .build();
channel.basicPublish("", "orders", properties, "{\"orderId\":\"order-42\"}".getBytes());

Queue length limits and overflow behaviour

x-max-length (message count) and x-max-length-bytes (total body size) cap how large a queue’s ready backlog is allowed to get — unacknowledged, in-flight deliveries do not count against either limit. Whichever limit is reached first is the one enforced, and what happens next depends on the queue’s overflow setting:

overflow Behaviour

drop-head (default)

The oldest ready message is dropped to make room for the new one — silent, oldest-first eviction.

reject-publish

The newly published message itself is rejected instead — with publisher confirms enabled, the publisher sees a basic.nack for it.

reject-publish-dlx

The same rejection as reject-publish, but the rejected message is additionally dead-lettered (maxlen reason, above) rather than simply discarded.

Map<String, Object> arguments = Map.of(
        "x-max-length", 10_000,
        "x-overflow", "reject-publish-dlx",
        "x-dead-letter-exchange", "orders.dlx");
channel.queueDeclare("orders", true, false, false, arguments);

Message priorities

A priority queue is declared with x-max-priority (an integer ceiling on the priority property a message may carry — RabbitMQ’s own guidance is to keep this small, roughly 1 to 10, since each extra priority level adds broker-side bookkeeping cost). Within such a queue, higher-priority ready messages are delivered ahead of lower-priority ones, though ordering is only a best-effort preference, not a hard real-time guarantee — a message already being delivered is not preempted by one of higher priority arriving after it.

Map<String, Object> queueArguments = Map.of("x-max-priority", 10);
channel.queueDeclare("orders", true, false, false, queueArguments);

AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder().priority(9).build();
channel.basicPublish("", "orders", properties, "{\"orderId\":\"order-42\"}".getBytes());

Delayed delivery

RabbitMQ has no single, long-standing broker-native way to delay a message’s first delivery, and what exists here is worth checking against the current docs rather than assuming, since it has changed more than once:

  • The community delayed-message exchange plugin (x-delayed-message, a per-message x-delay header in milliseconds) is the long-standing approach, but its upstream repository is now archived and no longer maintained by the RabbitMQ team — it does not run on current RabbitMQ releases at all, and was always capped at roughly 100,000 queued delayed messages with no cluster replication even when it did.

  • RabbitMQ 4.3 added a genuinely broker-native feature in this space, but a narrower one than the plugin: delayed retries on quorum queues — not a delay on first publish, but a Raft-replicated delay applied to a message’s redelivery after it comes back negatively acknowledged or released. x-delayed-retry-type, x-delayed-retry-min and x-delayed-retry-max (queue arguments or policy keys) configure a linear back-off, min(x-delayed-retry-min * delivery-count, x-delayed-retry-max), so a message redelivered a first, second and third time waits progressively longer, capped at the configured maximum. An individual message can also carry its own delay via the AMQP 1.0 x-opt-delivery-time annotation. Because the broker sets the message aside internally rather than removing and re-publishing it, this avoids both the message-rewrite cost and the redelivery-duplication risk the retry topology below has to work around by hand.

Retry topologies: a wait queue with TTL back to the work queue

Before 4.3’s native delayed retries (above), and still the portable approach on classic queues or on RabbitMQ versions without that feature, a delayed-retry effect is built by hand from two ordinary primitives already covered on this page: a per-queue TTL, and a dead-letter exchange pointed back at the original work queue. A message that fails processing is published (or dead-lettered) to a wait queue with no consumers and a fixed TTL instead of being retried immediately; once that TTL elapses, the wait queue’s own DLX configuration dead-letters it straight back into the work queue’s exchange, where an active consumer picks it up again — the TTL wait becomes the retry delay, entirely without the consumer sleeping or polling.

sequenceDiagram participant C as Consumer participant W as work queue participant Wait as wait queue (TTL only) participant DLX as retry DLX C->>W: consume message Note over C: processing fails -- not worth retrying immediately C->>Wait: publish to wait queue (basic.nack, requeue=false, or a direct publish) Note over Wait: message sits until its TTL elapses -- this is the retry delay Wait->>DLX: TTL expired -- dead-lettered DLX->>W: republished back to the work queue's exchange W->>C: redelivered to an active consumer
// wait queue: no consumers, fixed TTL, dead-letters straight back to the work exchange on expiry
Map<String, Object> waitArguments = Map.of(
        "x-message-ttl", 10_000,                 // 10 second retry delay
        "x-dead-letter-exchange", "",              // default exchange -- routes by queue name
        "x-dead-letter-routing-key", "orders");    // back to the work queue
channel.queueDeclare("orders.wait", true, false, false, waitArguments);

channel.basicNack(delivery.getEnvelope().getDeliveryTag(), false, false); // requeue=false -- goes where the
                                                                            // queue's own DLX argument sends it

Each retry attempt still needs a bounded number of hops — typically tracked from the x-death header’s own count field, or, on a quorum queue, simply left to the built-in x-delivery-limit — so a message that keeps failing eventually lands in a genuine dead-letter queue for manual inspection instead of cycling forever.