What you'll learn
Quick Answer
A dead letter queue (DLQ) is a separate queue a message gets moved to after it has failed processing some maximum number of times, instead of being retried forever or silently dropped. It stops one broken message from blocking a queue or looping endlessly, and it gives you an actual place to inspect, fix, and replay the messages that couldn't be processed normally.
Retries are good, until they aren't
Retries exist because most failures are transient: a downstream service is briefly down, a network call times out, a database connection pool is momentarily exhausted. Wait a bit and try again, and the message usually goes through.
Some failures aren't transient, though. A malformed payload, a message that violates a business rule, or a bug in the handler will fail exactly the same way on attempt one thousand as it did on attempt one. Retrying those forever wastes CPU and, worse, can actively harm the rest of the queue.
Many real queues process messages roughly in order. If a consumer keeps pulling the same failing message, retrying it, and putting it back at the front (or the queue keeps redelivering it because it was never acknowledged), every message behind it waits. This is head-of-line blocking: one poison message stalls an entire queue of otherwise-healthy work.
The fix is to give failing messages a limit, and somewhere else to go once they hit it.
Max attempts, then move it aside
Every message tracks how many times it's been attempted — most managed queues do this automatically (Amazon SQS calls it ReceiveCount, for example). You configure a maximum, say 3.
Each time processing fails, the attempt count goes up and the message goes back into the queue for another try. Once the count reaches the configured maximum, instead of retrying again, the message is moved to a dedicated dead-letter queue — a separate queue that exists purely to hold messages that couldn't be processed.
The main queue keeps moving: every message behind the failing one gets processed normally instead of waiting on retries that were never going to succeed. The DLQ becomes a holding area you can inspect on your own schedule, without it blocking live traffic.
This is a small but important separation of concerns — the main queue's job is throughput, and the DLQ's job is "don't lose this, but don't let it slow anything else down either."
Retry-then-DLQ — real run
A minimal version: each message tracks its own attempt count, failures push it back to the queue until it hits MAX_ATTEMPTS, then it moves to a dead-letter array instead.
if (message.attempts >= MAX_ATTEMPTS) {
deadLetterQueue.push({ ...message, lastError: err.message });
} else {
queue.push(message); // retry: back of the queue
}
Seeding one message that always fails alongside two that succeed normally, and running it, produces this real output:
[OK] msg-1 succeeded on attempt 1: processed msg-1: {"order":101}
[FAIL] msg-2 attempt 1/3: processing failed for msg-2 (payload: {"order":102})
[OK] msg-3 succeeded on attempt 1: processed msg-3: {"order":103}
[FAIL] msg-2 attempt 2/3: processing failed for msg-2 (payload: {"order":102})
[FAIL] msg-2 attempt 3/3: processing failed for msg-2 (payload: {"order":102})
[DLQ] msg-2 moved to dead-letter queue after 3 attempts
Succeeded: [ 'msg-1', 'msg-3' ]
Dead-letter queue: [ { id: 'msg-2', attempts: 3, lastError: '...' } ]
msg-1 and msg-3 never touch the DLQ at all — only msg-2, and only after genuinely exhausting its retries.
The DLQ is a queue, not a graveyard
A dead letter queue nobody monitors is just data loss with extra steps. Messages land there, processing quietly stops, and unless someone is watching DLQ depth, the first sign of trouble is a customer asking where their order went.
Put an alert on it — even something as simple as "page someone if the DLQ has more than zero messages for longer than five minutes" is far better than silence.
Don't blindly replay DLQ messages back into the main queue either. If the underlying cause wasn't fixed, a replayed message fails the exact same way and just burns through its retries again. Diagnose first, fix the handler or the data, then replay.
And make sure DLQ entries carry context, not just the bare payload — the error message, the timestamp, and the attempt count, at minimum. A payload with no explanation of why it failed turns every DLQ investigation into guesswork.
Where this shows up
Amazon SQS has DLQs built in via a redrive policy — set a max receive count on a queue and point it at a target DLQ. RabbitMQ has dead-letter exchanges, configured per queue, that route rejected or expired messages elsewhere. Kafka doesn't have a native per-message DLQ, so teams commonly build the same pattern manually: a retry topic with a delay, and a separate dead-letter topic for messages that exhaust their retries.
Background job frameworks apply the identical idea under different names — Sidekiq calls it the dead set, BullMQ calls it the failed queue — but the mechanics are the same: bound the retries per job, and park anything that keeps failing somewhere a human can review instead of leaving it to loop or vanish.
The same principle shows up outside message queues too. Webhook senders that retry a delivery a handful of times and then record it as permanently failed, rather than retrying forever, are applying this exact idea: bound the retries, and give the failure case somewhere concrete to land instead of silence.
