Dead-Letter Queue Patterns

BEMessage QueuesReliabilityArchitecture
Share on LinkedIn

A payment processing worker crashes on message #47,831. The message goes back to the queue. The worker picks it up again, crashes again. Every consumer instance that touches message #47,831 fails. The queue backs up. Legitimate payments wait behind a single poison message that will never succeed.

Dead-letter queues (DLQs) exist to handle exactly this. They are the pressure relief valve of reliable messaging — a holding area for messages that failed processing after exhausting retries, where operators can inspect, diagnose, and replay them without blocking the main queue.

How dead-letter queues work

Main Queue → Consumer → Success → Done
                ↓ Failure
             Retry (1..N)
                ↓ Still failing
          Dead-Letter Queue → Manual inspection / Replay

Configuration defines:

SQS dead-letter queue setup

import boto3

sqs = boto3.client("sqs")

# Create DLQ
dlq = sqs.create_queue(QueueName="payment-processing-dlq")

# Create main queue with redrive policy
main_queue = sqs.create_queue(
    QueueName="payment-processing",
    Attributes={
        "RedrivePolicy": json.dumps({
            "deadLetterTargetArn": dlq["QueueArn"],
            "maxReceiveCount": "5",
        }),
        "VisibilityTimeout": "60",
    },
)

Consumer with proper error handling:

def process_messages():
    while True:
        messages = sqs.receive_message(
            QueueUrl=MAIN_QUEUE_URL,
            MaxNumberOfMessages=10,
            WaitTimeSeconds=20,
        ).get("Messages", [])

        for msg in messages:
            try:
                payload = json.loads(msg["Body"])
                validate(payload)
                process_payment(payload)
                sqs.delete_message(
                    QueueUrl=MAIN_QUEUE_URL,
                    ReceiptHandle=msg["ReceiptHandle"],
                )
            except ValidationError as e:
                # Non-retryable: send to DLQ immediately
                logger.error(f"Invalid message: {e}", extra={"body": msg["Body"]})
                sqs.delete_message(QueueUrl=MAIN_QUEUE_URL, ReceiptHandle=msg["ReceiptHandle"])
                sqs.send_message(QueueUrl=DLQ_URL, MessageBody=msg["Body"],
                                 MessageAttributes={"failure_reason": {"StringValue": str(e), "DataType": "String"}})
            except TransientError:
                # Retryable: let visibility timeout expire for automatic retry
                logger.warning("Transient failure, will retry")
            except Exception as e:
                # Unknown error: let retry count increment toward DLQ
                logger.exception(f"Processing failed: {e}")

Distinguish retryable from non-retryable errors. Validation failures should never retry — they will fail identically every time.

Kafka dead-letter topic

Kafka does not have built-in DLQ routing. Implement it in the consumer:

from confluent_kafka import Consumer, Producer

def consume_with_dlq():
    consumer = Consumer({"bootstrap.servers": "kafka:9092", "group.id": "payment-processor"})
    dlq_producer = Producer({"bootstrap.servers": "kafka:9092"})
    consumer.subscribe(["payments"])

    while True:
        msg = consumer.poll(1.0)
        if msg is None:
            continue

        retry_count = get_retry_count(msg.headers())

        try:
            process(json.loads(msg.value()))
            consumer.commit(msg)
        except NonRetryableError as e:
            send_to_dlq(dlq_producer, msg, str(e))
            consumer.commit(msg)
        except Exception as e:
            if retry_count >= MAX_RETRIES:
                send_to_dlq(dlq_producer, msg, f"Max retries exceeded: {e}")
                consumer.commit(msg)
            else:
                republish_with_retry(msg, retry_count + 1)

def send_to_dlq(producer, original_msg, reason):
    producer.produce(
        "payments-dlq",
        value=original_msg.value(),
        headers=[
            ("original_topic", original_msg.topic()),
            ("original_partition", str(original_msg.partition())),
            ("original_offset", str(original_msg.offset())),
            ("failure_reason", reason),
            ("failed_at", datetime.utcnow().isoformat()),
        ],
    )
    producer.flush()

Include metadata in DLQ messages so replay knows the origin.

Retry strategies

Strategy Behavior Best for
Immediate retry Reprocess instantly Idempotent, fast operations
Fixed delay Wait N seconds between retries Rate-limited downstream APIs
Exponential backoff 1s, 2s, 4s, 8s, 16s Most production workloads
Exponential + jitter Backoff with random variance High-concurrency systems
def calculate_backoff(retry_count: int, base: float = 1.0, max_delay: float = 300.0) -> float:
    delay = min(base * (2 ** retry_count), max_delay)
    jitter = random.uniform(0, delay * 0.1)
    return delay + jitter

Jitter prevents thundering herd when many messages fail simultaneously and retry at the same instant.

Monitoring DLQs

A growing DLQ is an alert, not an archive:

# CloudWatch alarm for SQS DLQ depth
{
    "AlarmName": "payment-dlq-not-empty",
    "MetricName": "ApproximateNumberOfMessagesVisible",
    "Namespace": "AWS/SQS",
    "Dimensions": [{"Name": "QueueName", "Value": "payment-processing-dlq"}],
    "Threshold": 1,
    "ComparisonOperator": "GreaterThanOrEqualToThreshold",
    "EvaluationPeriods": 1,
    "Period": 300,
}

Dashboard metrics:

Replay procedures

When the root cause is fixed:

def replay_dlq(dlq_url: str, target_url: str, max_messages: int = 100, dry_run: bool = True):
    messages = sqs.receive_message(QueueUrl=dlq_url, MaxNumberOfMessages=max_messages).get("Messages", [])

    for msg in messages:
        failure_reason = msg.get("MessageAttributes", {}).get("failure_reason", {}).get("StringValue")
        logger.info(f"Replaying message (failed because: {failure_reason})")

        if not dry_run:
            sqs.send_message(QueueUrl=target_url, MessageBody=msg["Body"])
            sqs.delete_message(QueueUrl=dlq_url, ReceiptHandle=msg["ReceiptHandle"])

Always replay in batches with monitoring. If the fix is wrong, you do not want thousands of messages failing again instantly.

Common production mistakes

Teams get message queue dead letter handling wrong in predictable ways:

Production implementations of message queue dead letter handling fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Debugging and triage workflow

When message queue dead letter handling misbehaves in production, work top-down instead of guessing:

  1. Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
  2. Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
  3. Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
  4. Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
  5. Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
  6. Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.

Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.

Resources

Frequently asked questions

When should a message go to the dead-letter queue?

Send a message to the DLQ after exhausting retries — typically 3–5 attempts with exponential backoff. Also route messages that fail validation immediately (malformed payload, missing required fields) since retrying will never succeed. Do not DLQ transient failures like network timeouts on the first attempt.

How do I replay messages from a dead-letter queue?

Inspect the failure reason, fix the root cause (bug, schema change, downstream outage), then replay by re-publishing messages to the original queue. Use a replay tool that preserves message attributes and respects rate limits. Always replay to a staging queue first if the fix is unverified.

What is a poison message?

A poison message causes the consumer to crash or fail every time it is processed — corrupted data, a bug triggered by a specific payload, or a message referencing a deleted resource. Without a DLQ and max retry limit, poison messages block the queue indefinitely as they are retried forever.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →