What a dead letter queue is and which messages belong in it

A dead letter queue (DLQ) is a separate queue or topic where a consumer moves messages it cannot process, so they stop blocking or looping through the main flow. Treat it as a quarantine, not a trash can. Every message in it is a business event that did not happen: an order not fulfilled, a refund not issued. In a dead letter queue microservices setup, each DLQ needs a named owning team, a reason attached to every message, and a defined path back to processing or to deliberate discard.

The main question is which failures belong there. Poison messages fail deterministically. The same payload will fail on every attempt because the problem is in the message or in how the code handles it. Transient failures come from the environment and often succeed if you wait.

Failure type Examples Retry helps? Route to DLQ
Poison (deterministic) Schema violation, missing required field, payload that triggers a null-pointer or parsing bug No Immediately, or after one confirming attempt
Transient Timeout, dependency unavailable, throttling or rate-limit response Usually Only after the retry budget is exhausted

Retrying a poison message wastes capacity and adds noise to logs. On ordered partitions it also stalls every message behind it, so send it to the DLQ fast. Sending transient failures there too early is the opposite mistake: it fills the DLQ with work that would have succeeded, and operators learn to ignore it.

Any team running asynchronous consumers where a single message can fail repeatedly should have a DLQ.

How message retry and dead-lettering work together

When a consumer hits a retriable failure, it should not block the partition by retrying in place. It republishes the message to a delay queue or retry topic with an incremented attempt-count header, acknowledges or commits the original, and moves on. Many brokers cannot delay individual messages, so teams create one tier per backoff step (for example retry-1s, retry-4s, retry-16s). Each tier's consumer waits until the message is due and then sends it back to the main topic. Once the attempt limit is reached, the message goes to the DLQ along with its failure metadata. Healthy messages behind it are never delayed.

Add jitter to every delay. Without it, messages that failed at the same moment retry at the same moment and hit a recovering dependency all at once.

Illustrative estimate, using assumed inputs: 1,000 msg/s, 2% failure rate, 5 retries, 1-second base, doubling backoff with full jitter.

  • Worst-case delay before dead-lettering: 1 + 2 + 4 + 8 + 16 = 31 seconds, plus processing time.
  • Extra broker load at a 2% failure rate: 20 failing msg/s × 5 retries ≈ 100 extra writes and reads per second, roughly 10% overhead.
  • Extra broker load during a full dependency outage: 1,000 msg/s × 5 ≈ 5,000 extra deliveries per second, five times normal traffic, aimed at a service that is already down.

That last case is the risk of aggressive retries. They multiply load during an outage, and they can make a deterministic bug look like intermittent flakiness. Too few retries cause the opposite problem: a 30-second blip dead-letters work that would have succeeded. Size the backoff window to cover the recovery time you expect from your dependencies. Also add a circuit breaker so retries pause while a dependency is down, instead of using up their attempt budget.

+----------+      +--------------+      +--------------------+
| producer |----->|  main topic  |----->|   main consumer    |--> success:
+----------+      +--------------+      +--------------------+    commit
                         ^                 |            |
                         |                 | retriable  | attempts
                         |                 | failure    | exhausted
                         |                 v            v
                         |       +------------------+  +-----------------+
                         |       | republish copy,  |  |       DLQ       |
                         |       | attempt += 1,    |  | payload + error |
                         |       | commit original  |  | + attempt count |
                         |       +------------------+  +-----------------+
                         |                 |
                         |                 v
                         |   +--------------------------+
                         |   | retry-1s   (delay+jitter)|
                         |   | retry-4s   (delay+jitter)|
                         |   | retry-16s  (delay+jitter)|
                         |   +--------------------------+
                         |                 |
                         |                 v
                         |   +--------------------------+
                         +---| tier consumer: wait until|
                             | due, resend to main topic|
                             +--------------------------+

  circuit breaker open (dependency down) => pause retry tiers,
  so retries do not spend their attempt budget

Failed messages leave the main topic for jittered delay tiers and return when due, so healthy messages are never blocked; after the attempt limit they go to the DLQ with failure metadata.

Implementing error classification and failure metadata in consumers

Classification belongs at the edge of the consumer's handler, not scattered through business logic. Wrap the processing call in one place that catches failures and maps each one to a decision: retry, dead-letter now, or let it crash. Base that mapping on the type of exception rather than on matching message strings, because strings change between library releases and locales. Timeouts, connection resets, and throttling responses are retryable. Deserialization failures, schema violations, and validation errors are not. The same bytes will fail the same way on every attempt, so retrying them only delays triage and holds up the partition or queue behind them. Treat unknown exception types as retryable but cap their attempts. That way a new bug surfaces in the DLQ within minutes, and a transient fault is not misfiled as permanent.

When a message is dead-lettered, wrap it in an envelope rather than forwarding the bare payload. The envelope should record:

  • the original topic or queue
  • the consumer name
  • the attempt count
  • the timestamps of the first and last failures
  • the error class and message
  • the correlation or trace ID

Together these let an on-call engineer answer three questions without reproducing the failure: which service rejected the message, for how long, and why. The trace ID also links the DLQ entry back to the request that produced it.

Rich metadata and full payloads cost something. Stack traces can be larger than the message itself. Full payloads can carry personal data into a store that has weaker access controls, and they raise storage costs. A reasonable default is to keep the full payload with a truncated error message, and to store stack traces in your logs, keyed by trace ID.

import json, time

RETRYABLE = (TimeoutError, ConnectionResetError)  # transient: same bytes may succeed later
PERMANENT = (json.JSONDecodeError, ValueError, KeyError)  # same bytes fail every time
MAX_KNOWN_RETRIES, MAX_UNKNOWN_RETRIES, MAX_ERR_LEN = 8, 3, 500

def classify(exc, attempt):
    # Decide on exception type, never on message text
    if isinstance(exc, PERMANENT):
        return "dead_letter"
    if isinstance(exc, RETRYABLE):
        return "retry" if attempt < MAX_KNOWN_RETRIES else "dead_letter"
    # Unknown types: assume transient, but cap low so new bugs reach the DLQ fast
    return "retry" if attempt < MAX_UNKNOWN_RETRIES else "dead_letter"

def handle(msg, process, send_to_dlq, schedule_retry, log):
    """msg: dict with topic, value (bytes), trace_id, attempt, first_failure_ts."""
    try:
        process(msg["value"])  # the only place failures are caught
    except Exception as exc:
        attempt = msg.get("attempt", 0) + 1
        now = time.time()
        first = msg.get("first_failure_ts") or now
        # Full stack trace goes to logs, keyed by trace ID, not into the DLQ
        log.exception("processing failed trace_id=%s", msg["trace_id"])
        if classify(exc, attempt) == "retry":
            schedule_retry({**msg, "attempt": attempt, "first_failure_ts": first})
            return
        send_to_dlq({
            "original_topic": msg["topic"],
            "consumer": "orders-consumer",
            "attempt_count": attempt,
            "first_failure_ts": first,
            "last_failure_ts": now,
            "error_class": type(exc).__qualname__,
            "error_message": str(exc)[:MAX_ERR_LEN],  # truncated to bound size
            "trace_id": msg["trace_id"],
            "payload": msg["value"].decode("utf-8", errors="replace"),  # full payload: mind PII
        })

A broker-agnostic Python consumer wrapper that classifies exceptions by type into retry or dead-letter, caps attempts for unknown errors, and sends a DLQ envelope containing failure metadata, a truncated error message and the full payload, while the stack trace goes to logs under the trace ID.

Idempotent consumers and a safe DLQ replay path

Every message in a DLQ arrives there under at-least-once delivery, and replay sends it again. Some of those messages partially succeeded before failing: the database write committed but the acknowledgement was lost, or the email went out and the next step threw. Replay will produce duplicates. Consumers must tolerate them before anyone presses the replay button.

Making consumers idempotent

  • Idempotency keys. Derive a stable key from the business operation, such as an order ID plus an action, not from a broker-assigned delivery ID that changes on republish. Pass the key to downstream services that support one, so a retried payment call cannot charge twice.
  • Dedupe tables. Insert the message key into a table with a unique constraint in the same transaction as the state change. A constraint violation means the message was already processed, so the consumer acknowledges it and moves on.
  • Natural upserts with a version guard. "Set status to SHIPPED where version < 7" is safe to repeat. It also stops a stale message from overwriting newer state, which is the quieter replay failure.

The replay tool

The tool should list and filter messages by error class, time range, and source before it does anything. It should support a dry run that reports what would be sent. It should publish to the original topic or a dedicated replay queue at a configurable rate, so a backlog does not flood a dependency that has only just recovered. It should increment a replay-count header and refuse messages past a cap, which stops replay loops. It should also write an audit record of the messages replayed, who replayed them, and when.

-- PostgreSQL. One-time setup: a dedupe table keyed by the business operation,
-- not by the broker's delivery ID (which changes when a message is republished).
CREATE TABLE processed_messages (
  idempotency_key text PRIMARY KEY,           -- e.g. '<order_id>:<action>'
  processed_at    timestamptz NOT NULL DEFAULT now()
);

-- Per message: the consumer runs this in ONE transaction, so the dedupe
-- record and the state change commit together or not at all.
BEGIN;

-- Claim the key. ON CONFLICT turns a duplicate into a no-op instead of an error.
-- The consumer checks rows affected: 0 means this operation was already applied,
-- so it issues ROLLBACK, acknowledges the message, and stops.
INSERT INTO processed_messages (idempotency_key)
VALUES ('order-81234:ship')
ON CONFLICT (idempotency_key) DO NOTHING;

-- Version-guarded update: safe to repeat, and a stale replayed message
-- (carrying version 7) cannot overwrite an order already at version 8 or later.
-- 0 rows affected means newer state exists; the consumer commits the dedupe
-- row and acknowledges without changing the order.
UPDATE orders
SET    status = 'SHIPPED',
       version = 7,
       updated_at = now()
WHERE  order_id = 81234
  AND  version < 7;

COMMIT;

A PostgreSQL consumer transaction that records a business idempotency key in a dedupe table and applies a version-guarded update, so a redelivered or replayed message is applied at most once and cannot overwrite newer state.

Ordering-sensitive streams: halt, skip, or park

Everything so far assumes messages are independent. On a keyed or partitioned stream they often are not. If an account's debit event fails and its next credit event succeeds, the consumer has applied the credit against state the debit never touched. When a poison message appears mid-stream, there are three ways to respond.

Strategy What happens to later messages for the key Correctness Throughput impact Choose when
Halt Consumer stops the whole partition until the message is fixed or removed Strict ordering preserved Every key sharing the partition stalls Few keys per partition, or a total-order ledger where any gap is unacceptable
Skip with marker Poison message goes to the DLQ; a marker records the gap; processing continues Order is broken for that key; downstream must tolerate or detect gaps None Events are self-contained snapshots, or the latest state supersedes earlier events
Park key All later messages for that key are diverted to a parking store until the poison is resolved Per-key order preserved Only the affected key is delayed Many independent keys share a partition and per-key order matters

Halting is the simplest correct option. Its cost scales with partition density: one bad message can block thousands of unrelated customers. Skipping keeps the pipeline moving but pushes the problem downstream, where consumers have to read the marker and decide whether their state is still valid. Parking usually gives the best balance, but it introduces new state. The consumer needs a durable record of which keys are parked so it can check every incoming message against it. Replay must drain the poison message first and then the parked messages in their original offset order. Releasing them out of sequence breaks the very ordering that parking was meant to protect.

Operating DLQs: ownership, alerting, retention, and sensitive data

A DLQ that nobody watches is a slower way of dropping messages. Treat it as an operational signal, not a storage bin.

Alert on rate and age, not just depth

Depth alone misleads. A queue holding 500 messages from a known, triaged incident is less urgent than one that went from zero to five in the last ten minutes. Alert on two things. The first is arrival rate, which catches new failure modes as they start. The second is age of the oldest message, which catches failures that have been sitting unhandled. Age matters most when retention is finite. If the oldest message is approaching its expiry, you are about to lose data with no error raised anywhere.

Assign an owner and an expiry

Every DLQ needs a named owning team that receives its alerts and decides whether to replay, fix, or discard. Set retention deliberately and longer than your realistic triage time. Define what happens at expiry, such as archiving to cold storage with an audit record, so that nothing disappears without anyone noticing.

Aspect Per-service DLQ Shared DLQ
Ownership Clear: the consuming team Diffuse unless strictly tagged
Selective replay Straightforward Requires filtering by source and type
Alert noise Scoped to one team One failing service pages everyone
Infrastructure count Higher Lower

Sensitive data

Dead-lettered payloads are full copies of production messages, and they often include personal data. Apply the same access control, encryption at rest, and retention limits as the primary data store. Deletion and erasure requests must reach the DLQ as well.

When a DLQ is the wrong tool

Skip the DLQ if no team will own it. In that case it only hides failures. A DLQ is also insufficient when every message is critical and must not be held back. For those flows, halting the consumer and paging someone is safer than quietly parking the message.

Alternatives, development cost, and engineering time saved

A DLQ is one option among several. The right choice depends on what a stuck message costs and who will act on it.

Approach Fits better than a DLQ when Main risk
Stop-on-error consumer Strict ordering, every message critical, someone on call One bad message stalls the whole stream
In-place retry, logging only Failures are almost always transient and volume is low Permanent failures loop forever or disappear into logs
Parking-lot table in a database Operators need ad hoc queries or joins with business data You hand-build the schema and replay, effectively a second queue
Drop with audit record Messages are low value or superseded by newer state Silent data loss if a failure is misclassified

The DLQ path has real upfront cost. You need error classification, failure metadata on every dead-lettered message, idempotent handlers, and replay tooling with access controls. That cost is paid once. The payoff recurs with every incident: triage starts from structured metadata instead of log searches, and replay replaces hand-written repair scripts.

Illustrative estimate (assumed numbers, not a benchmark)

  • Build cost B = 15 engineer-days
  • Incidents per year N = 6
  • Hours saved per incident H = 4 (triage) + 3 (log searching) + 5 (manual repair) = 12
  • Annual savings = N × H / 8 = 6 × 12 / 8 = 9 engineer-days
  • Payback period = B / annual savings = 15 / 9 ≈ 1.7 years

Substitute your own incident history. Idempotency also pays off outside DLQ replay, because broker redeliveries hit the same handlers. So the true payback is usually shorter than this formula suggests.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to dead letter queue microservices, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.