Skip to content

Designing a Resilient Consumer

Each reliability lesson gave you one safety net. A production consumer needs all of them working together. This capstone assembles them into a single design you can adapt.

A consumer you can trust in production has these properties:

  1. Durable queue + persistent messages — so a broker restart doesn’t lose work.
  2. Manual acknowledgement — ack only after the work succeeds, so a crash mid-task redelivers.
  3. Sensible prefetch — a bounded prefetch (say 10–50) so one consumer isn’t handed thousands of unacked messages.
  4. Idempotent processing — because delivery is at-least-once, the same message can arrive twice; make handling it twice harmless (dedupe on a message id).
  5. Retry with backoff, then park — on failure, nack without immediate requeue; route through a TTL + dead-letter retry queue; after N attempts, send to a parking (dead) queue for a human.
  6. Connection recovery — reconnect automatically when the broker connection drops, and re-declare topology.
  7. Graceful shutdown — on SIGTERM, stop accepting new deliveries, finish in-flight work (and ack it), then close the channel and connection.
  8. Observability — log and emit metrics for processed, retried, and parked messages so you can see failures.
flowchart TB
  deliver["Message delivered
(prefetch-bounded)"] --> dedupe{"Seen this id?"}
  dedupe -->|yes| ackdup["ack (idempotent skip)"]
  dedupe -->|no| work["Process work"]
  work -->|success| ack["ack"]
  work -->|failure| retry{"Attempts < N?"}
  retry -->|yes| ttl["nack -> retry queue (TTL) -> back to main"]
  retry -->|no| park["route to parking queue
(alert a human)"]
A resilient consumer's handling of one message

The details differ per client, but the shape is the same everywhere: bounded prefetch, manual ack on success, dead-letter on repeated failure, and a shutdown hook.

import amqp from 'amqplib';
const conn = await amqp.connect('amqp://localhost');
const channel = await conn.createChannel();
await channel.assertQueue('tasks', { durable: true });
channel.prefetch(20); // bounded in-flight work
channel.consume('tasks', async (msg) => {
if (!msg) return;
const id = msg.properties.messageId;
try {
if (await alreadyProcessed(id)) { channel.ack(msg); return; } // idempotent
await doWork(msg.content);
await markProcessed(id);
channel.ack(msg); // ack only after success
} catch (err) {
const attempts = (msg.properties.headers?.['x-attempts'] ?? 0) + 1;
if (attempts >= 5) channel.nack(msg, false, false); // -> DLX/parking
else republishToRetryQueue(channel, msg, attempts); // TTL + DLX retry
channel.ack(msg); // remove the original
}
});
process.on('SIGTERM', async () => { // graceful shutdown
await channel.close();
await conn.close();
process.exit(0);
});

If you remember only three things from this whole course, make them these: ack after success, never before (it’s the difference between losing work and not); make processing idempotent (because at-least-once guarantees duplicates); and never requeue a poison message in a tight loop (use TTL + dead-letter retry and a parking queue). Everything else is refinement on those three.

When should a resilient consumer acknowledge a message?
Why must processing be idempotent?
What is the safe way to handle a message that keeps failing?
What should graceful shutdown do on SIGTERM?