A payload arrives that the webhook handler can't parse. The handler errors; the broker counts that as a failed delivery and sends the message again; the parse fails the same way; and the loop runs until the retention window closes, and the message disappears with its contents still unread.

A dead letter queue gives that failure a destination instead of an expiry date. The queue itself is the easy part, though. Which broker you run barely affects whether it works, because the classification logic your consumer runs long before a message ever reaches quarantine determines that.

This guide covers the trigger conditions that route a message out of the main queue, the classification that distinguishes a recoverable failure from a permanent one, and the cost of operating a dead letter queue once it exists.

**Key takeaways:**

- A dead letter queue (DLQ) stores messages that a system can't process after retries are exhausted, when a time to live (TTL) expires, or when a consumer rejects them outright.

- A malformed payload fails on every retry, so a DLQ works only once the consumer can distinguish between them.

- Alerting at any depth above zero is what keeps a DLQ from becoming a message graveyard, because a tolerance band is exactly where failures accumulate unnoticed.

- Replaying dead-lettered messages before the consumer fix ships re-poisons the queue within minutes, and the delivery counts come back wrong too.

- [Vercel Queues](https://vercel.com/docs/queues) hands poison message handling to your application code, where the `retry` callback and `deliveryCount` metadata let the consumer decide what counts as unrecoverable.

## [Copy link to heading](#what-is-a-dead-letter-queue)What is a dead letter queue?

A dead letter queue is a holding queue that receives messages the messaging system can't or shouldn't deliver to their intended consumer. A message arrives there when it exhausts its delivery budget, ages past its retention window, or gets rejected by a consumer that recognizes it as unprocessable.

A message that fails on every single attempt, however large its budget, is called a poison message. The queue can't make a poison message processable again, but it keeps the payload and the failure reason where someone can act on them.

None of this is new, and that's worth knowing before you start comparing brokers. The [Dead Letter Channel](https://camel.apache.org/components/4.18.x/eips/dead-letter-channel.html) formalized it decades before managed queues existed, under a single rule. An undeliverable message gets a destination instead of being deleted.

Brokers vary on trigger conditions and parameter names while all implementing that same rule, so [which broker you pick](https://vercel.com/i/rabbitmq-vs-kafka) rarely decides whether your DLQ works.

### [Copy link to heading](#what-are-the-differences-between-a-dead-letter-queue-and-a-retry-queue)What are the differences between a dead letter queue and a retry queue?

Delivery budget is the dividing line. Messages in a retry queue still have attempts left and are expected to succeed, whereas messages in a dead letter queue have exhausted their budget and need intervention before they move again.

The table below compares the three places a message can sit and what each one implies about who acts next:

| Dimension | Source queue | Retry queue | Dead letter queue |
| --- | --- | --- | --- |
| Delivery attempts remaining | Full budget | Some remaining | None |
| System expectation | Processes on first delivery | Succeeds on a later attempt | Won't succeed without a change |
| Next actor | The consumer | The consumer, after a delay | A person or a triage job |
| Exit condition | Acknowledgment | Acknowledgment or budget exhaustion | Inspection, then replay or expiry |
| Failure signal | None | Transient | Permanent, or a budget that ran out |

Only the last column needs someone to act. A message sitting in a dead letter queue has already told you something upstream is broken, and nothing else in the system will pick that up for you.

### [Copy link to heading](#benefits-of-dead-letter-queues-for-production-teams)Benefits of dead letter queues for production teams

The value appears at the moment when a message would otherwise be deleted. With no destination configured, an unprocessable payload and the evidence of why it failed leave the system together, on a retention clock nobody is watching.

Four things change once failed messages have somewhere to go:

- **Evidence that outlives the message:** An unprocessable payload with no DLQ expires at its TTL, taking the details with it. A dead letter queue keeps the payload and the error, so you can read them later.

- **Throughput that survives one bad message:** On an ordered queue, a single poison message can hold up everything behind it. Moving it aside lets the rest drain.

- **Compute you stop paying for:** A message that fails the same way every time keeps consuming invocations until its retention runs out, and batched consumers retry the whole batch alongside it. Quarantining it ends both costs.

- **A recovery path instead of a write-off:** Dead-lettered messages can be replayed once the consumer bug is fixed. Messages that expired in the source queue are gone.

All four depend on one precondition: that someone or something looks at the queue. Most DLQ implementations fail on exactly that.

## [Copy link to heading](#how-does-a-dead-letter-queue-work)How does a dead letter queue work?

A message moves to the DLQ when one of a small set of conditions fires. Brokers use different names and defaults, but the same four triggers show up everywhere.

### [Copy link to heading](#trigger-conditions-that-move-a-message-to-a-dead-letter-queue)Trigger conditions that move a message to a dead letter queue

Four conditions route a message out of the main queue, and only one of them is a choice your code makes:

- **Maximum delivery count exceeded:** The message has been delivered more times than the queue allows, against a limit you configure.

- **Explicit consumer rejection:** Your code tells the broker the message is unprocessable instead of failing and letting the retry budget drain on its own.

- **Message TTL expired:** The message aged out of its retention window before any delivery succeeded.

- **Deserialization failure on every attempt:** The consumer can't parse the message, so it fails each time identically and exhausts the delivery count without any attempt succeeding.

Those delivery counts aren't always exact. [Google Cloud Pub/Sub](https://docs.cloud.google.com/pubsub/docs/dead-letter-topics) treats its maximum as approximate, so a message can move after fewer attempts than configured, or after more.

None of these triggers asks whether the message deserved to move. A payload with an unparseable body and a payload that timed out five times while a downstream service restarted both hit the same delivery-count limit, and the broker treats them identically. It's the consumer's job to tell those two apart.

### [Copy link to heading](#transient-and-permanent-failures-need-opposite-treatment)Transient and permanent failures need opposite treatment

Every message failure falls into one of two categories, and the category decides whether a retry is useful or wasteful. Network timeouts, 503 responses, and rate limits are transient and resolve on a later attempt without anyone changing anything. Malformed payloads, schema mismatches, auth errors, and references to deleted data are permanent, failing identically on attempt 1 and attempt 50.

Getting this wrong is expensive in both directions. Dead-letter on the first failure and a transient blip becomes an operational ticket, because a message that a retry would have fixed now needs a human to put it back in the queue. Retry a permanent failure through its full budget and the system burns invocations and delays everything behind it, all to reach the conclusion available on attempt one.

Two rules follow, and both are narrow. Never dead-letter a transient error, and never spend the remaining budget on a permanent one. Writing that classification is application work. No broker can do it, because a broker sees an exception and a count, not the failure semantics of the API your handler called.

### [Copy link to heading](#what-happens-before-a-message-reaches-a-dead-letter-queue)What happens before a message reaches a dead letter queue

Retry mechanics run first, independently of any DLQ policy. Vercel Queues retries failed messages automatically until they expire, and for the first [32 delivery attempts,](https://vercel.com/docs/queues/concepts) it honors the retry delay you configure. Past 32, it forces exponential backoff to stop runaway redelivery.

Retries are built for transient failures, and on every broker, they leave permanent ones untouched by design. A message that can never succeed keeps cycling until its TTL runs out. On Vercel Queues, retention is [configurable per message](https://vercel.com/docs/queues/api), from 60 seconds to 7 days, with a default of 24 hours, which limits how long it can run.

## [Copy link to heading](#core-components-of-a-production-dead-letter-queue)Core components of a production dead letter queue

Provisioning the queue is the shortest task in the whole effort. Four components make it useful after the first message lands, and skipping any one produces a queue that accumulates failures nobody reads.

### [Copy link to heading](#failure-metadata-captured-at-dead-letter-time)Failure metadata captured at dead-letter time

A dead-lettered message with no context is nearly worthless for triage. By the time anyone opens the queue, the deployment that produced the failure may be gone, and the surrounding logs may have rolled off.

The context has to be recorded at quarantine time, which means the original topic, partition, and offset; the error message and exception type; the delivery attempt count; the [application version](/blog/version-skew-protection) at failure; and a trace ID linking back to the originating request. Teams underrate the version field, because most poison messages are produced by one deployment and discovered under another.

### [Copy link to heading](#alerting-that-treats-any-depth-as-a-signal)Alerting that treats any depth as a signal

Any depth above zero is the alert threshold. For SQS, the [documented approach](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/dead-letter-queues-alarms-cloudwatch.html) is to set an alarm on `ApproximateNumberOfMessagesVisible` for the dead letter queue, with inspection when it fires, on the grounds that a healthy system moves nothing there at all.

Any tolerance band above zero creates a window in which failures can accumulate without a response, and that window is how queues can reach thousands of messages before anyone notices. There's a real cost to this.

A zero threshold will page someone at 3 a.m. for one malformed payload from a third-party sender. Severity routing handles that better than a raised threshold because raising the threshold removes the signal rather than the noise.

### [Copy link to heading](#a-named-owner-and-a-retention-clock)A named owner and a retention clock

A dead letter queue nobody drains is a slower, more expensive way to lose data. Most production failures here come from a DLQ that already exists, has no owner, fires no alert, and expires its contents on a schedule nobody tracks.

Retention turns neglect into deletion. On AWS SQS, the [retention ceiling](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html) is 14 days, with a 4-day default, so an unowned DLQ is a scheduled data-loss event with extra configuration. Name an owner before the first message arrives, and set the DLQ's retention longer than the source queue's so nothing expires while it waits for triage.

### [Copy link to heading](#a-gated-replay-path)A gated replay path

Nothing gets replayed until the consumer fix is live. A redrive pushes dead-lettered messages back through the consumer, so running one before the fix ships re-poisons the queue within minutes, and now the delivery counts are wrong too.

Run the replay in a fixed order:

1.  Alert on the queue and inspect the payload.

2.  Find the root cause in the consumer.

3.  Ship the fix through your normal [deployment checks](https://vercel.com/docs/deployment-checks).

4.  Replay a small sample and confirm it processes cleanly.

5.  Redrive the rest with rate limiting.

Step 5 matters most after a downstream outage, when the DLQ holds the full backlog and the recovering dependency is least prepared to receive it. A redrive path nobody has exercised is a bad thing to discover mid-incident, so run it under controlled conditions first.

## [Copy link to heading](#how-vercel-handles-dead-letter-queue-patterns-for-engineering-teams)How Vercel handles dead letter queue patterns for engineering teams

Vercel Queues has no platform-managed dead-letter queue, and this omission is deliberate. A platform can see that a message failed and count the attempts, but it can't tell whether a 500 from a third-party API is a restart that clears on its own or an auth error that never will. Your consumer can, because it knows what it called.

Vercel supplies the delivery count and control over when a message stops retrying, which is enough to build the quarantine your workload needs.

### [Copy link to heading](#handle-poison-messages-in-application-code-with-the-retry-callback)Handle poison messages in application code with the retry callback

Most teams never write the classification logic at all. A platform-managed DLQ makes the queue feel handled, so the team sets a max receive count, ships, and finds it full of unclassified messages months later.

The `handleCallback` function in the [`@vercel/queue`](https://vercel.com/docs/queues/sdk) [SDK](https://vercel.com/docs/queues/sdk) accepts a `retry` option, and that callback receives the error plus a metadata object carrying `deliveryCount`. Returning `{ acknowledge: true }` stops the retries, at which point your code routes the payload wherever your team wants it:

```
import { handleCallback } from '@vercel/queue';

export const POST = handleCallback(
  async (message, metadata) => {
    await fulfillOrder(message);
  },
  {
    retry: (error, metadata) => {
      if (metadata.deliveryCount > 5) {
        return { acknowledge: true };
      }
      const delay = Math.min(300, 2 ** metadata.deliveryCount * 5);
      return { afterSeconds: delay };
    },
  },
);
```

Returning `{ afterSeconds }` instead controls backoff per error type, which is where the branch conditions from your classification table belong. Poll-mode consumers read the same count from the `Vqs-Delivery-Count` response header. The [`vercel-labs/workflow-dead-letter-queue`](https://github.com/vercel-labs/workflow-dead-letter-queue) repository is a working reference implementation.

### [Copy link to heading](#keep-a-failing-message-from-blocking-the-consumer)Keep a failing message from blocking the consumer

On an ordered broker, a single poison message can hold up everything behind it, which is why teams reach for a DLQ under time pressure rather than by design. Quarantine turns urgent because the throughput has already stopped.

Vercel Queues removes that urgency. Messages with no delivery attempts are always prioritized over retried ones, so a poison message falls to lower priority on its own while the consumer keeps working through new messages, and even at max concurrency 1, it can't block the consumer.

Priority-based delivery has a cost of its own, since Queues deliver in approximate write order with no first-in, first-out guarantee, so workloads needing strict ordering reorder on the consumer side with sequence numbers.

### [Copy link to heading](#match-the-failure-handling-primitive-to-the-workload)Match the failure-handling primitive to the workload

Not every background job needs a queue, and routing everything through one rebuilds retry and quarantine logic for work that already had it. Which primitive fits depends on what the workload needs when a step fails.

These four differ on retry behavior and on where failure information ends up:

| Primitive | Retries on failure | Failure routing |
| --- | --- | --- |
| Vercel Queues | Automatic until the message expires, with forced exponential backoff after 32 attempts | Application code, through the `retry` callback |
| [Vercel Workflows](https://vercel.com/docs/workflows) | 3 retries per step by default, and a `FatalError` skips them | Every step, input, output, and error recorded per run |
| [Cron Jobs](https://vercel.com/docs/cron-jobs) | None | Runtime logs only |
| `after()` and `waitUntil()` | None, terminated at the function timeout | None |

Vercel Workflows is the closest thing to a built-in solution for multi-step business logic, because the run record already holds the inputs, outputs, and errors for every step, and work that spans service boundaries usually takes the form of the [saga pattern](https://vercel.com/i/saga-pattern). Vercel Queues sits underneath it, for when you want direct control over publish and consume behavior.

### [Copy link to heading](#isolate-schema-changes-with-deployment-scoped-topics)Isolate schema changes with deployment-scoped topics

Schema mismatches are one of the largest sources of poison messages, and rollouts are when they appear. A new deployment changes a payload shape, the previous deployment's consumer can't deserialize it, and the queue fills with messages that were valid at publish.

On Vercel, topics are partitioned by deployment ID by default, and in push mode messages go back to the same deployment that published them. Each deployment produces and consumes its own messages, which removes one of the largest sources of DLQ entries during a rollout. Poll-mode consumers running outside Vercel can use the deployment ID as an opaque version identifier for manual partitioning.

## [Copy link to heading](#best-practices-for-dead-letter-queue-design)Best practices for dead letter queue design

Most DLQ entries are preventable, and the design choices that prevent them cost less than the triage they replace. These belong before the queue.

### [Copy link to heading](#classify-errors-before-the-first-poison-message-arrives)Classify errors before the first poison message arrives

Classification logic usually gets written mid-incident, under time pressure and against whichever dependency happens to be failing. Written calmly, it becomes an artifact rather than a reflex, enumerated by dependency rather than byinstead of a reflex, enumerated per dependency rather than per error class, and it lives in the repository next to the consumer that reads it.

Build the table by listing the failure responses of every dependency your consumer calls and assigning each one a handler action:

| Dependency response | Classification | Handler action |
| --- | --- | --- |
| 429, 503, or network timeout | Transient | Retry with backoff |
| 401 or 403 | Permanent | Dead-letter immediately |
| 422 schema violation | Permanent | Dead-letter immediately |
| 404 on referenced data | Permanent | Dead-letter immediately |

Those rows become the branch conditions in your handler, which is why the table earns a place in version control rather than in an incident doc.

### [Copy link to heading](#add-exponential-backoff-with-jitter-before-adding-a-dead-letter-queue)Add exponential backoff with jitter before adding a dead letter queue

A DLQ used as a substitute for backoff floods itself with recoverable failures. Fixed-interval retries across a fleet of consumers synchronize, so every client hits the recovering dependency at the same moment and knocks it over again.

Jitter, a random offset added to each retry delay, breaks that synchronization. With 100 contending clients, [full jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) cut the total call count by more than half against plain exponential backoff, and improved time to completion.

You pay for that in unpredictable per-message retry timing, which complicates worst-case latency estimates, and the [full set of tradeoffs](https://vercel.com/i/exponential-backoff) is worth settling before a DLQ enters the design.

### [Copy link to heading](#make-handlers-idempotent-so-retries-stop-manufacturing-failures)Make handlers idempotent so retries stop manufacturing failures

At-least-once delivery produces duplicates, and a handler with a non-idempotent side effect turns each duplicate into a failure. A double charge or duplicate insert errors, the retry budget drains, and a valid message lands in the DLQ.

Deduplication on a unique message ID handles most of this, and operations that set a value rather than increment one are naturally idempotent. For more on the failure shapes this covers and the ones it doesn't, see the guide on [API idempotency](https://vercel.com/i/what-is-idempotency).

### [Copy link to heading](#validate-schemas-at-publish-time-instead-of-consume-time)Validate schemas at publish time instead of consume time

When producers and consumers share a validated schema contract, a payload that can't deserialize fails at publish. The producer gets a synchronous error with a stack trace pointing at the bug, which beats finding a message in quarantine three hours later.

This closes the largest category of poison messages for internally produced traffic. It does nothing for third-party webhooks, where the sender can change formats without notice, which is exactly the workload that still needs a DLQ.

### [Copy link to heading](#open-a-circuit-breaker-during-a-total-outage)Open a circuit breaker during a total outage

A DLQ is built for partial, message-specific failures. During a total downstream outage every message fails, so the DLQ catches the entire in-flight volume and a blind redrive slams the newly recovered dependency with the full backlog.

An open circuit breaker changes the outcome. Messages accumulate in the source queue, where retention protects them, instead of draining retry budgets and flooding the DLQ. Breakers add state and tuning of their own, and a miscalibrated one trips on normal error rates and stalls a healthy pipeline.

## [Copy link to heading](#ship-async-work-with-failure-handling-you-can-see)Ship async work with failure handling you can see

Provisioning a dead letter queue takes an afternoon. Owning it takes longer, and the operating discipline around it separates a recovery tool from a message graveyard. It comes down to classification written before the first incident, alerting at zero instead of a comfortable threshold, a named owner, and a replay path that ships the fix first. Skipping that work means rebuilding it mid-outage, with a backlog growing and the clock running.

Here's how Vercel supports that discipline for teams running async workloads:

- **Vercel Queues with an application-level** `**retry**` **callback:** The `deliveryCount` field and the `{ acknowledge: true }` return value put classification in the code that knows the failure semantics, so it gets written at all.

- **Priority-based delivery that protects throughput:** New messages always outrank retried ones, so a poison message can't block a consumer even at max concurrency 1, and quarantine stays a design decision instead of an emergency.

- **Deployment-scoped topics:** Topics partitioned by deployment ID keep schema changes from producing cross-version poison messages during a rollout.

- **Vercel Workflows for multi-step logic:** Three retries per step by default, `FatalError` for permanent failures, and a durable run record of every step, input, output, and error.

- **Durable delivery with configurable retention:** Every message is written synchronously to three availability zones before publish returns, with retention configurable from 60 seconds to 7 days.

[Start a new project](https://vercel.com/new) to put these patterns into production, or [browse templates](https://vercel.com/templates) for background job examples already wired up with queue consumers.

## [Copy link to heading](#frequently-asked-questions-about-dead-letter-queues)Frequently asked questions about dead letter queues

### [Copy link to heading](#what-is-the-difference-between-a-dead-letter-queue-and-a-retry-queue)What is the difference between a dead letter queue and a retry queue?

A retry queue holds messages with delivery attempts remaining, and a dead letter queue holds messages that have exhausted them. If a redrive runs on a timer and nobody inspects the payloads, that's a slow retry queue wearing a DLQ label.

### [Copy link to heading](#do-vercel-queues-have-a-built-in-dead-letter-queue)Do Vercel Queues have a built-in dead letter queue?

No. Vercel Queues ships without a platform-managed DLQ, so the quarantine destination is whatever your code writes to, commonly a database table or an alerting channel. The `retry` callback decides when a message stops retrying, and your handler decides where it goes.

### [Copy link to heading](#what-should-the-alerting-threshold-for-a-dead-letter-queue-be)What should the alerting threshold for a dead letter queue be?

Zero, paired with severity-based routing rather than a raised threshold. Alarm on queue depth rather than on messages sent, because messages moved by a redrive policy don't increment the [send metric](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-available-cloudwatch-metrics.html), which leaves slow leaks invisible.

### [Copy link to heading](#what-is-a-poison-message)What is a poison message?

A poison message fails on every delivery attempt because it is permanently unprocessable. In the logs, it shows up as an identical stack trace with a climbing delivery count, which distinguishes it from a dependency outage that hits unrelated messages at once.

### [Copy link to heading](#how-do-you-safely-replay-messages-from-a-dead-letter-queue)**How do you safely replay messages from a dead letter queue?**

Ship the consumer fix, then replay a handful of messages spanning the distinct error types in the queue rather than the oldest few, since one failure mode is rarely the only one present. Redrive the remainder with rate limiting once that sample clears.