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):
| Attempt | Wait |
|---|---|
| 1 → 2 | delayMs (500ms) |
| 2 → 3 | delayMs × 2 (1000ms) |
| 3 → 4 | delayMs × 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:
- Rabbit Relay checks the retry count
- If attempts remain, it republishes the message
- It increments retry headers
- It acknowledges the original message only after the retry copy is published
- After max attempts, it applies the final behavior
Retry headers
Rabbit Relay stores retry metadata in message headers:
| Header | Description |
|---|---|
x-rabbit-relay-retry-count | Attempt number (1-based) |
x-rabbit-relay-retry-delay-ms | Actual wait scheduled before this attempt |
x-rabbit-relay-first-failed-at | ISO timestamp of the first failure |
x-rabbit-relay-last-failed-at | ISO timestamp of the latest failure |
x-rabbit-relay-last-error | Last 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.
| Value | Behavior |
|---|---|
dead-letter (default) | nack with requeue=false |
requeue | nack with requeue=true |
ack | acknowledge 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.