0Pricing
Node.js Backend Development Bootcamp · Lesson

Acknowledgements, Dead-Letter Queues, and Retries

Guarantee delivery with manual acks, handle poison messages, and implement retry and dead-letter flows.

Acknowledgements, Dead-Letter Queues, and Retries is a free Node.js Backend Development Bootcamp lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Node.js Backend Development Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Acknowledgements Matter

By default, when a consumer receives a message from RabbitMQ it must tell the broker whether the message was processed successfully. This signal is called an acknowledgement (ack).

If you enable auto-ack, RabbitMQ deletes a message the instant it is delivered. If your worker then crashes mid-processing, the message is gone forever. With manual acks, the broker keeps the message until you confirm it, and re-delivers it if the consumer dies.

  • auto-ack: fast, but at-most-once delivery (can lose messages)
  • manual ack: safe, gives you at-least-once delivery

For any job that does real work (charging a card, sending email, writing to a DB), you almost always want manual acks.

Consuming With Manual Acks

Using the amqplib library, you pass { noAck: false } to channel.consume to enable manual acknowledgement. After your handler finishes successfully, you call channel.ack(msg).

Until you ack, RabbitMQ considers the message unacked and will redeliver it if the channel or connection closes.

const amqp = require('amqplib');

async function start() {
  const conn = await amqp.connect('amqp://localhost');
  const channel = await conn.createChannel();
  const queue = 'orders';

  await channel.assertQueue(queue, { durable: true });

  channel.consume(queue, async (msg) => {
    if (msg === null) return;
    const order = JSON.parse(msg.content.toString());
    console.log('Processing order', order.id);
    // ... do real work here ...
    channel.ack(msg); // confirm success
  }, { noAck: false });
}

start();

ack, nack, and reject

RabbitMQ gives you three ways to respond to a delivered message:

  • channel.ack(msg) — success, remove the message
  • channel.nack(msg, false, requeue) — failure; if requeue=true the message goes back to the queue, if false it is discarded (or dead-lettered)
  • channel.reject(msg, requeue) — like nack but for a single message only

The second argument of nack is allUpTo. Set it to false to act only on the current message; true nacks every unacked message up to this one.

Key decision: requeue=true retries immediately and can cause infinite hot-loops for poison messages. Prefer requeue=false plus a dead-letter strategy.

// Failure with no requeue -> message is dropped or dead-lettered
channel.nack(msg, false, false);

// Failure with requeue -> message returns to the front of the queue
channel.nack(msg, false, true);

// reject only ever affects this one message
channel.reject(msg, false);

Prefetch: Controlling In-Flight Work

Without limits, RabbitMQ pushes as many messages as it can to a consumer, all sitting unacked in memory. That can overwhelm a slow worker.

channel.prefetch(n) sets the maximum number of unacknowledged messages a consumer may hold at once. With manual acks, a worker with prefetch(1) processes one message, acks it, then receives the next — giving fair, back-pressured dispatch.

const channel = await conn.createChannel();
await channel.assertQueue('orders', { durable: true });

// Only one unacked message at a time per consumer
await channel.prefetch(1);

channel.consume('orders', async (msg) => {
  await handle(msg);
  channel.ack(msg);
}, { noAck: false });

What Is a Poison Message?

A poison message is one that always fails processing — bad JSON, a reference to a deleted record, an unrecoverable validation error. If you simply nack(msg, false, true) it, the message requeues, gets redelivered, fails again, and loops forever, pinning your CPU.

You need a way to give up after a few tries and move the message somewhere safe for inspection. That somewhere is a Dead-Letter Queue (DLQ).

  • Stop retrying after N attempts
  • Move the message out of the main flow
  • Keep it for debugging / manual replay

Dead-Letter Exchanges

RabbitMQ can automatically route a message to another exchange when it is dead-lettered. A message is dead-lettered when:

  • it is nacked/rejected with requeue=false, or
  • its TTL expires, or
  • the queue exceeds its max length

You configure this with the queue arguments x-dead-letter-exchange (required) and optionally x-dead-letter-routing-key. Bind a queue to that exchange and dead messages land there.

// Main queue routes failures to the 'dlx' exchange
await channel.assertExchange('dlx', 'direct', { durable: true });
await channel.assertQueue('orders.dlq', { durable: true });
await channel.bindQueue('orders.dlq', 'dlx', 'orders.dead');

await channel.assertQueue('orders', {
  durable: true,
  arguments: {
    'x-dead-letter-exchange': 'dlx',
    'x-dead-letter-routing-key': 'orders.dead'
  }
});

Sending a Failure to the DLQ

Once the orders queue has a dead-letter exchange configured, dropping a message there is simply a matter of nacking without requeue.

The broker handles the routing for you — you never publish to the DLQ manually. This keeps your consumer logic clean: process, and on unrecoverable failure, let RabbitMQ dead-letter it.

channel.consume('orders', async (msg) => {
  try {
    const order = JSON.parse(msg.content.toString());
    await processOrder(order);
    channel.ack(msg);
  } catch (err) {
    console.error('Unrecoverable failure:', err.message);
    // requeue=false -> RabbitMQ dead-letters to orders.dlq
    channel.nack(msg, false, false);
  }
}, { noAck: false });

Counting Retries With Headers

Often you want to retry a few times before giving up — a transient DB timeout might succeed on the second try. RabbitMQ has no built-in retry counter, so you track attempts yourself.

When a message is dead-lettered, RabbitMQ adds an x-death header array describing each dead-letter event, including a count. You can read it to decide whether to retry or to route to the final DLQ.

function deathCount(msg) {
  const xDeath = msg.properties.headers && msg.properties.headers['x-death'];
  if (!Array.isArray(xDeath) || xDeath.length === 0) return 0;
  // sum counts across dead-letter events for this reason
  return xDeath.reduce((sum, d) => sum + (d.count || 0), 0);
}

const attempts = deathCount(msg);
if (attempts >= 3) {
  channel.nack(msg, false, false); // give up -> parking DLQ
} else {
  // route back for another attempt
}

Delayed Retries With a TTL Wait Queue

Immediate requeue retries instantly, which is bad for transient errors that need time to clear. A common pattern is a retry queue with a TTL that dead-letters back to the main queue.

Flow: main queue fails → message goes to orders.retry which has x-message-ttl (e.g. 5s) and a dead-letter exchange pointing back to orders. After the TTL expires, RabbitMQ moves it back automatically — giving a built-in delay between attempts.

// Retry queue: holds messages for 5s, then dead-letters back to main
await channel.assertQueue('orders.retry', {
  durable: true,
  arguments: {
    'x-message-ttl': 5000,
    'x-dead-letter-exchange': '',          // default exchange
    'x-dead-letter-routing-key': 'orders'  // back to main queue
  }
});

// On a retryable failure, publish into the wait queue instead of nacking
channel.sendToQueue('orders.retry', msg.content, {
  persistent: true,
  headers: msg.properties.headers
});
channel.ack(msg); // ack the original; the copy is now waiting

Putting the Retry Logic Together

Here is the decision flow a robust consumer follows on failure:

  • Parse/validate fails permanently → dead-letter to the parking DLQ immediately
  • Transient error and attempts < max → push to the retry (TTL) queue for a delayed re-attempt
  • Transient error but attempts ≥ max → give up, send to the parking DLQ

This bounds total work, avoids hot-loops, and preserves failed messages for inspection or manual replay.

const MAX_RETRIES = 3;

channel.consume('orders', async (msg) => {
  let order;
  try {
    order = JSON.parse(msg.content.toString());
  } catch (e) {
    return channel.nack(msg, false, false); // poison -> parking DLQ
  }

  try {
    await processOrder(order);
    channel.ack(msg);
  } catch (err) {
    const attempts = retryHeader(msg);
    if (attempts >= MAX_RETRIES) {
      channel.nack(msg, false, false);    // exhausted -> parking DLQ
    } else {
      channel.sendToQueue('orders.retry', msg.content, {
        persistent: true,
        headers: { ...msg.properties.headers, 'x-retries': attempts + 1 }
      });
      channel.ack(msg);                    // ack original; retry is queued
    }
  }
}, { noAck: false });

A Runnable Retry Simulation

The RabbitMQ behavior needs a broker, but the retry decision logic is plain JavaScript you can reason about and test in isolation. Below is a self-contained simulation of the ack / retry / dead-letter decision an order consumer makes.

It demonstrates the core idea: succeed and ack, retry while under the limit, and park to the DLQ once retries are exhausted.

const MAX_RETRIES = 3;

function decide(message) {
  // simulate processing: 'good' succeeds, 'bad-json' is poison, else transient
  if (message.kind === 'good') return { action: 'ack' };
  if (message.kind === 'bad-json') return { action: 'dlq', reason: 'poison' };

  const attempts = message.retries || 0;
  if (attempts >= MAX_RETRIES) return { action: 'dlq', reason: 'exhausted' };
  return { action: 'retry', retries: attempts + 1 };
}

const inbox = [
  { id: 1, kind: 'good' },
  { id: 2, kind: 'bad-json' },
  { id: 3, kind: 'transient', retries: 0 },
  { id: 4, kind: 'transient', retries: 3 }
];

for (const msg of inbox) {
  const result = decide(msg);
  console.log('msg ' + msg.id + ' ->', JSON.stringify(result));
}

Quick Check

You have a consumer using manual acks. A message contains malformed JSON that can never be parsed. You want to stop it from looping forever while keeping it for later inspection.

Recap

You learned how to make RabbitMQ delivery reliable and resilient:

  • Manual acks (noAck: false + channel.ack) give at-least-once delivery so a crash never loses a message.
  • nack/reject with requeue=false hands a failed message to a dead-letter exchange instead of looping forever.
  • prefetch(n) back-pressures slow workers by capping unacked messages.
  • Dead-letter exchanges (x-dead-letter-exchange) auto-route failures to a DLQ for inspection and replay.
  • Retries use a TTL wait queue that dead-letters back to the main queue, with an attempt counter (via x-death or a custom header) to cap retries before parking the message.

Together these patterns give you bounded, observable, lossless message processing.

Frequently asked questions

Is the “Acknowledgements, Dead-Letter Queues, and Retries” lesson free?

Yes — the full text of “Acknowledgements, Dead-Letter Queues, and Retries” is free to read here on the web, and the Node.js Backend Development Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Node.js Backend Development Bootcamp course, upgrade to CoddyKit PRO.

What will I learn in “Acknowledgements, Dead-Letter Queues, and Retries”?

Guarantee delivery with manual acks, handle poison messages, and implement retry and dead-letter flows. You practise Node.js Backend Development Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Node.js Backend Development Bootcamp?

No prior experience is required. Node.js Backend Development Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Acknowledgements, Dead-Letter Queues, and Retries” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Node.js Backend Development Bootcamp lesson?

Yes. Every Node.js Backend Development Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Producers, Consumers, and the AMQP Model
  2. Exchange Types: Direct, Topic, Fanout, and Headers
  3. Acknowledgements, Dead-Letter Queues, and Retries
  4. Work Queues, Prefetch, and Competing Consumers
← Back to Node.js Backend Development Bootcamp