← All articles
Production Messaging in .NET

Dead Letter Queue: Quarantine Failed Messages Without Hiding Them

Operate dead-letter queues as bounded quarantine with failure classification, ownership, safe replay, and evidence that the original defect is fixed.

  • dead-letter-queue
  • messaging
  • dotnet
  • azure-service-bus
  • rabbitmq
  • retry
  • poison-message
  • observability
  • replay
0:000:00

Introduction

Retry handles failures expected to heal. Idempotency makes repetition safe. Some messages still cannot complete:

  • the payload violates a contract;
  • the consumer no longer supports the version;
  • authorization or routing is misconfigured;
  • a business prerequisite will never exist;
  • repeated processing exceeds the broker’s delivery policy.

A Dead Letter Queue (DLQ) removes such a message from the hot delivery path while preserving it for diagnosis and controlled recovery.

A DLQ does not fix anything. It is quarantine. Without ownership, alerting, retention, and replay controls, it becomes a quieter form of message loss.

The Production Problem

One malformed message enters a queue with five consumers. Every consumer:

  1. receives it;
  2. fails deserialization;
  3. abandons it;
  4. receives it again.

The message consumes capacity, floods logs, increases delivery count, and delays healthy traffic. Immediate retries cannot repair an invalid payload.

Moving it to a DLQ protects throughput. But if no one notices, the related order remains stuck indefinitely. If an operator blindly replays the entire DLQ, the poison message returns and valid old messages may repeat business effects.

The production decision has two parts:

  • when should a failure leave the normal retry path?
  • what controlled process returns it, replaces it, or closes it permanently?

Core Concepts

Dead-lettering is a settlement outcome

Broker behavior differs, but common causes include:

  • explicit reject or dead-letter by the consumer;
  • maximum delivery count exceeded;
  • message time-to-live expired;
  • routing or transfer failure;
  • queue length or broker-specific policy.

Record the actual broker reason and application failure classification. Do not infer every dead-letter was a poison payload.

Transient and permanent are operational classes

Transient failures may heal: throttling, temporary network loss, short database failover.

Permanent failures require a change: invalid schema, unsupported contract, forbidden access, missing destination, or a business rule that will not change.

Some failures are uncertain. After a bounded retry period, quarantine them for diagnosis rather than retrying forever.

DLQ is different from delayed retry

A retry queue or persisted schedule represents work expected to run again automatically at a known time.

A DLQ represents work removed from normal automation pending a policy decision. Mixing both makes it impossible to tell whether intervention is required.

Replay is a new production change

Replay can:

  • repeat non-idempotent effects;
  • violate ordering;
  • overwhelm recovered consumers;
  • reintroduce incompatible contracts;
  • hide evidence by changing message metadata.

Every replay needs scope, rate, reason, owner, and postcondition.

Quarantine needs terminal dispositions

Not every dead-letter should return to the original queue. Valid outcomes include:

  • replay unchanged after infrastructure repair;
  • transform through an approved migration and publish as a new message;
  • compensate or correct business state;
  • mark permanently discarded with reason and audit evidence;
  • retain for legal or incident analysis;
  • route to manual business handling.

How It Works

  1. The consumer validates the envelope before domain processing.
  2. It classifies failures as transient, permanent, stale, duplicate, or unknown.
  3. Transient failures use bounded retry and backoff.
  4. Permanent failures are dead-lettered immediately with a safe reason code.
  5. Unknown failures dead-letter after the defined attempt or age limit.
  6. Monitoring alerts the owning team.
  7. Operators diagnose a representative sample and identify the shared cause.
  8. The defect or dependency is repaired and verified.
  9. An idempotency-aware, rate-limited replay handles only the approved set.
  10. Operators verify queue drain and business-state reconciliation.

Architecture Diagram

Message failure classification and dead-letter recoveryA consumer processes a message. Transient failures enter bounded delayed retry. Permanent or exhausted failures enter a dead-letter queue. Operators diagnose, repair, approve a bounded replay, and verify business state. Success or duplicate Transient Permanent or exhausted Replay bounded set Correct / discard / manual Main queue Consumer Outcome Complete Bounded delayed retry Dead Letter Queue Diagnose + repair Approved disposition Rate-limited replay Audited closure
Message failure classification and dead-letter recovery

.NET Implementation

Azure Service Bus exposes explicit settlement through ServiceBusProcessor.

processor.ProcessMessageAsync += async args =>
{
    try
    {
        var envelope = DeserializeAndValidate(args.Message);

        var result = await consumer.HandleAsync(
            envelope,
            args.CancellationToken);

        switch (result)
        {
            case ConsumeResult.Processed:
            case ConsumeResult.AlreadyProcessed:
            case ConsumeResult.Stale:
                await args.CompleteMessageAsync(
                    args.Message,
                    args.CancellationToken);
                break;

            case ConsumeResult.RetryableFailure:
                await args.AbandonMessageAsync(
                    args.Message,
                    cancellationToken: args.CancellationToken);
                break;

            case ConsumeResult.PermanentFailure:
                await args.DeadLetterMessageAsync(
                    args.Message,
                    deadLetterReason: "consumer-permanent-failure",
                    deadLetterErrorDescription:
                        "The consumer classified this message as permanent.",
                    cancellationToken: args.CancellationToken);
                break;

            default:
                throw new UnreachableException(
                    $"Unknown consume result: {result}.");
        }
    }
    catch (UnsupportedContractException exception)
    {
        await args.DeadLetterMessageAsync(
            args.Message,
            deadLetterReason: "unsupported-contract",
            deadLetterErrorDescription:
                SafeSummary(exception),
            cancellationToken: args.CancellationToken);
    }
    catch (TransientDependencyException)
    {
        await args.AbandonMessageAsync(
            args.Message,
            cancellationToken: args.CancellationToken);
    }
};

Use the current Azure.Messaging.ServiceBus SDK. Keep deadLetterReason stable and low-cardinality. Bound and sanitize descriptions; broker metadata has size limits and can expose sensitive data.

Do not catch every exception and dead-letter immediately. Unexpected failures may be transient or indicate a deployment defect affecting every message. Conversely, do not abandon a known invalid contract until maximum delivery count because it only burns capacity.

A replay tool should preserve original identity for the same logical message and attach replay metadata:

public sealed record ReplayMetadata(
    string ReplayId,
    string ApprovedBy,
    string ReasonCode,
    DateTimeOffset ReplayedAtUtc);

Avoid copying broker-managed delivery count or dead-letter annotations blindly. Broker APIs differ on which properties can be set. Store immutable audit evidence outside the message when necessary.

Common Mistakes

No owner or alert

A DLQ with no response objective is data loss with storage.

Replaying before fixing

The same message fails again, increases noise, and may move behind newer work.

Bulk replay at full speed

Historical volume can overload consumers and dependencies. Rate-limit and observe a small canary set first.

Changing message IDs casually

A new ID bypasses consumer deduplication and can repeat effects. Preserve identity for the same logical message.

Assuming all DLQ messages share one cause

Group by contract, reason, consumer version, first-failure time, and exception fingerprint before choosing a disposition.

Storing secrets in failure descriptions

Payloads and exception text can include credentials or personal data. Redact and apply access control.

Infinite dead-letter loops

Routing a DLQ back automatically without a terminal policy creates a slower retry loop. RabbitMQ can detect some cycles, but application topology should prevent them explicitly.

Ignoring ordering after replay

An old state-change message may arrive after newer events. Consumers need version checks or a reconciliation path.

Best Practices

  • Define transient, permanent, stale, and unknown failure classes.
  • Use delayed retry for expected recovery and DLQ for intervention.
  • Give every DLQ an owner, response objective, retention, and access policy.
  • Preserve original message, reason, contract, timestamps, and correlation.
  • Replay a filtered canary before a bounded batch.
  • Require idempotency and stale-message checks before replay.
  • Reconcile business state after transport recovery.
  • Audit transform, discard, and replay decisions.

Observability

Measure:

  • DLQ count and oldest-message age by entity;
  • dead-letter rate by stable reason and contract;
  • messages entering and leaving quarantine;
  • replay success, repeat-dead-letter, and processing latency;
  • unresolved business objects linked to dead letters.

Alert on the first message for critical workflows, sustained growth, oldest age beyond the response objective, and replay failure. A zero main-queue backlog does not mean the workflow is healthy if the DLQ grows.

Trace the original processing failure and replay as separate operations connected by message and replay identifiers. Do not create one span that remains open during days of quarantine.

Verification

Inject:

  • an unsupported contract;
  • a transient dependency outage;
  • a handler bug affecting all messages;
  • a message exceeding processing time;
  • an old event replayed after a newer version.

Verify classification, settlement, DLQ metadata, alert delivery, consumer progress for healthy messages, canary replay, deduplication, and business-state reconciliation.

Decision Matrix

FailureAction
Short database or network outageDelayed retry with backoff
Dependency rejects load temporarilyHonor delay, bound retries, use Circuit Breaker
Unsupported contract versionDead-letter immediately
Malformed payloadDead-letter immediately
Duplicate messageComplete as already processed
Stale versionComplete, ignore, or quarantine according to domain policy
Unexpected handler exception affecting many messagesPause or limit consumer, investigate deployment
Maximum delivery count reachedDead-letter and investigate why retries exhausted
Irreversible external outcome uncertainQuarantine and reconcile; do not replay blindly

Relationship to Other Patterns

Retry, Timeout, and Backoff decide how long transient failure remains automated. Circuit Breaker protects dependencies during broader outages.

Idempotent Consumer makes replay safe from duplicate effects. Saga must keep its durable state aligned with a dead-lettered step and may move to manual review.

Transactional Outbox can have a producer-side quarantine for rows that cannot be published. That is separate from the broker’s consumer DLQ.

Decision checklist

  • Which exact failures retry, dead-letter, ignore, or pause consumption?
  • Does every DLQ have an owner and response objective?
  • Are reasons stable, bounded, and sanitized?
  • Can healthy messages continue past a poison message?
  • Is replay idempotent and stale-message aware?
  • Must the original message ID be preserved?
  • Are replay scope, rate, approval, and verification recorded?
  • Does monitoring include oldest age and business impact?
  • Is there an audited permanent-disposition path?

Key takeaways

  • DLQ is quarantine, not recovery.
  • Permanent failures should not consume the transient retry budget.
  • Replay is a production change that can repeat effects and overload systems.
  • Transport drain must be followed by business reconciliation.
  • A silent DLQ is delayed data loss.

Sources