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.