Rabbit Relay

Exponential Backoff

Broker-native doubling waits between consumer retries

Immediate retries only help with deterministic in-process failures. For transient failures, a network blip, a downstream 429/503, a DB lock, the failure condition hasn't changed a millisecond later. Exponential backoff gives the dependency room to recover.

Configuration

await sub.consume({
  onError: "retry",
  retry: {
    attempts: 3,
    delayMs: 500,
    backoff: "exponential",
    then: "dead-letter",
  },
});

Attempt n waits delayMs * 2^(n-1):

AttemptWait
1 → 2delayMs (500ms)
2 → 3delayMs × 2 (1000ms)
3 → 4delayMs × 4 (2000ms)

Omit backoff (or use "fixed") to keep every wait at exactly delayMs. Immediate retry stays available by omitting delayMs entirely.

How it works under the hood

No setTimeout, nothing held in Node.js memory. Rabbit Relay declares one TTL parking exchange/queue pair per attempt, each with its own TTL:

jobs.q.retry.a1.exchange / jobs.q.retry.a1.500.queue    (TTL 500)
jobs.q.retry.a2.exchange / jobs.q.retry.a2.1000.queue   (TTL 1000)
jobs.q.retry.a3.exchange / jobs.q.retry.a3.2000.queue   (TTL 2000)

A failed message is republished to the parking exchange for its next attempt; when the TTL expires, RabbitMQ dead-letters it back to the original exchange. The business routing key survives the whole round trip.

Topology grows with attempts

Exponential backoff declares attempts parking pairs instead of one, so every delay is broker-native and visible in the RabbitMQ management UI. In topologyMode: "passive", all attempt pairs must already exist.

Retry behavior

When a handler throws:

  1. Rabbit Relay checks the retry count
  2. If attempts remain, it republishes the message
  3. It increments retry headers
  4. It acknowledges the original message only after the retry copy is published
  5. After max attempts, it applies the final behavior

Retry headers

Rabbit Relay stores retry metadata in message headers:

HeaderDescription
x-rabbit-relay-retry-countAttempt number (1-based)
x-rabbit-relay-retry-delay-msActual wait scheduled before this attempt
x-rabbit-relay-first-failed-atISO timestamp of the first failure
x-rabbit-relay-last-failed-atISO timestamp of the latest failure
x-rabbit-relay-last-errorLast error message (truncated to 500 chars)

These headers are copied into event.meta.headers for handlers and are visible in DLQ messages.

Final behavior

Use retry.then to choose what happens after retries are exhausted.

ValueBehavior
dead-letter (default)nack with requeue=false
requeuenack with requeue=true
ackacknowledge and drop

Inspecting retry state

Handlers can read the retry count from headers:

sub.handle("jobs.process", async (_id, ev) => {
  const retryCount = Number(
    ev.meta?.headers?.["x-rabbit-relay-retry-count"] ?? 0
  );

  if (retryCount < 2) {
    throw new Error("temporary failure");
  }
});

RabbitMQ also adds x-death headers when messages expire from retry queues. These are copied into event.meta.headers.

Best practices

  • Keep retry attempts small
  • Use DLQ after retries
  • Use delayed retry when a dependency may be temporarily unavailable
  • Make handlers idempotent
  • Monitor retry and DLQ volume
  • Do not use infinite requeue loops as a retry strategy

Runnable example

See examples/18-exponential-backoff in the repository, a self-contained consumer that prints the observed delivery timings so you can watch the doubling waits happen live.

On this page