Reliability Patterns
Publisher confirms, dead-letter exchanges for failed messages, and delayed retry strategies.
Publisher confirms: knowing the broker actually has it
channel.publish() returning doesn't mean RabbitMQ has durably stored your message — by default it only means the message left your process. A confirm channel gets you an asynchronous acknowledgment back from the broker once it has actually accepted and persisted the message:
const channel = await connection.createConfirmChannel();
channel.publish(exchange, routingKey, payload, { persistent: true }, (err, ok) => {
if (err) {
// the broker did not confirm this message — log it, retry, or alert
console.error('Publish not confirmed:', err);
}
});
Or, to publish a batch and wait for all of them to be confirmed before moving on:
channel.publish(exchange, routingKey, payload, { persistent: true });
await new Promise((resolve, reject) => {
channel.waitForConfirms((err) => (err ? reject(err) : resolve()));
});
Without confirms, a broker crash between "your process sent the bytes" and "the broker wrote them to disk" is invisible to your producer — it thinks the publish succeeded. Confirms turn that into an explicit, checkable outcome your code can act on (retry, alert, write to an outbox table for reconciliation).
Dead-letter exchanges: where failed messages go instead of vanishing
A message gets dead-lettered — routed to a separate, configured exchange instead of being silently dropped — in three situations: a consumer nacks it with requeue: false, it sits past its TTL, or the queue hits a configured maximum length. Configuring this turns "message disappeared, no idea why" into "message is sitting in a queue I can inspect":
// Where failed messages end up, for manual inspection
await channel.assertExchange('orders.dlx', 'fanout', { durable: true });
await channel.assertQueue('orders.dlq', { durable: true });
await channel.bindQueue('orders.dlq', 'orders.dlx', '');
// The real queue: anything nacked without requeue, or that expires, goes to orders.dlx
await channel.assertQueue('inventory.orders.queue', {
durable: true,
arguments: {
'x-dead-letter-exchange': 'orders.dlx',
},
});
Now a consumer's channel.nack(msg, false, false) — reject, don't requeue — routes the message to orders.dlq instead of destroying it. An operator (or an alert on that queue's depth) can inspect exactly what failed and why, instead of it disappearing without a trace.
Retry strategies: immediate vs. delayed
Requeuing a failed message immediately (nack(msg, false, true)) is fine for a transient blip, but for anything that needs a real backoff, it creates a tight loop that hammers a downstream dependency that may not have even had time to recover. RabbitMQ has no native delayed-message feature without a plugin, but the DLX mechanism above gives you a well-known workaround: a retry queue whose only job is to hold a message for a fixed delay, then dead-letter it back to the original exchange for another attempt:
// Messages land here after a failed attempt, sit for 30s doing nothing, then get
// dead-lettered back to the real exchange — nothing ever consumes from this queue directly.
await channel.assertExchange('orders.retry-dlx', 'fanout', { durable: true });
await channel.assertQueue('orders.retry.queue', {
durable: true,
arguments: {
'x-message-ttl': 30000, // 30 second delay
'x-dead-letter-exchange': 'orders.exchange', // bounce back to the real exchange...
'x-dead-letter-routing-key': 'order.eu.created', // ...with the original routing key
},
});
A consumer that fails processing nacks the message toward orders.retry-dlx (by setting that as the queue's dead-letter target on failure) rather than requeueing it directly — it waits out the TTL, then comes back around for another attempt automatically.
To cap how many times a message retries before giving up and routing it to the permanent dead-letter queue for manual review, use the x-death header array RabbitMQ automatically appends to a message every time it's dead-lettered — each entry records the exchange, queue, and reason, so counting entries for the current queue tells the consumer how many attempts have already happened, letting it route to orders.dlq instead of orders.retry.queue once a limit is reached.
Common mistakes
- No DLX configured at all — a message rejected with
requeue: falseis simply gone forever, with nothing in any log to explain why, and a message rejected withrequeue: trueon a permanently-broken payload loops forever instead. - Confusing "immediate requeue" with a real retry strategy — a tight requeue loop on a message that will never succeed just burns CPU and spams a downstream dependency that needed time to recover, not a millisecond-scale retry.
- Not capping retry attempts, letting a single malformed ("poison") message bounce between the retry queue and the real queue indefinitely instead of eventually landing in a dead-letter queue for a human to look at.
- Treating publisher confirms as a replacement for consumer acknowledgments, or vice versa — they protect two different legs of the same message's journey (producer-to-broker, and broker-to-consumer) and a reliable pipeline needs both.