Dead-Letter Queue Patterns
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:
- Max receive count: how many processing attempts before DLQ routing.
- Visibility timeout: how long a message is hidden after a consumer picks it up.
- Retry backoff: delay between retry attempts.
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:
- DLQ depth — messages waiting for inspection.
- DLQ arrival rate — new failures per minute.
- Retry success rate — messages that succeed after N attempts.
- Time in DLQ — how long messages sit before replay.
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:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
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:
- Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
- Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
- Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
- Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
- Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
- 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
- AWS SQS dead-letter queues documentation
- Apache Kafka consumer error handling
- Enterprise Integration Patterns: Dead Letter Channel
- Google Cloud Pub/Sub dead-letter topics
- RabbitMQ dead letter exchanges
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 →