Delivery and failure

Use dead letters and controlled replay

Move repeatedly failing messages aside, preserve evidence, fix the cause, and replay only through an idempotent path.

8 minute lesson

~~~

Some messages will never succeed, no matter how many retries you give them. A payload from a buggy deploy, a reference to a deleted record, an unexpected schema version. A poison message can consume retries forever or block useful work, so the queue needs somewhere to put it.

That somewhere is a dead-letter queue: a normal queue that collects messages after the configured delivery attempts are exhausted:

{
  "queues": {
    "consumers": [
      {
        "queue": "order-jobs",
        "max_retries": 5,
        "dead_letter_queue": "order-jobs-dlq"
      }
    ]
  }
}

After the fifth failed attempt, the message moves to order-jobs-dlq instead of being dropped. Without this setting, exhausted messages are deleted — the failure and its evidence vanish together.

A dead letter is evidence, so treat it that way. Alert on dead-letter growth and include safe failure context in your logs: which handler failed, which error class, which jobId. Check the backlog directly:

npx wrangler queues info order-jobs-dlq
# ...
# backlog size: 3

A backlog that was zero yesterday and is three hundred today is telling you a deploy broke something, hours before a customer does. And since payloads sit in the DLQ for days, sensitive payloads need the same retention and access controls as source data — the dead-letter queue is not exempt from your data rules.

Replay is a deliberate act

The wrong response to a full DLQ is pointing the consumer back at it and hoping. The messages failed for a reason; unfixed, they’ll fail again. Do not blindly replay everything: fix code or data first, select the messages that the fix actually addresses, preserve their operation IDs so idempotency checks still recognize them, and watch the result.

A small replay consumer on the DLQ does this well:

async queue(batch, env) {
  for (const message of batch.messages) {
    await env.ORDER_JOBS.send(message.body)  // original body, original IDs
    message.ack()
  }
}

Because the original jobId travels with the replayed message, work that partially completed before dying won’t run twice.

Rehearse the loop before you need it: force a schema-version failure, inspect its dead letter, deploy compatible handling, and replay that single message safely. One rehearsed message teaches you the whole procedure — selection, replay, verification — at a moment when nothing is on fire.

Lesson completed

Take this course offline

Get every free book and course as PDF and EPUB files.

Get the download library →