Web Development

Why I Stopped Treating Dead-Letter Queues as a Trash Bin

A

Adil Sher

Author

Oct 25, 2026
5 min read
0 views
Why I Stopped Treating Dead-Letter Queues as a Trash Bin

Three months ago, I shipped a reservation expiry system that silently swallowed failures. A payment hold stayed open for days past its window because a webhook timed out, nobody logged it, and the retry logic just gave up. The customer's balance looked wrong. My manager asked what happened. I had no visibility into it. That's when I realized I'd been thinking about message queues all wrong, not as pipelines that move data, but as state machines that must expose their failures.

The conversation that changed things for me came from an engineer at a fintech company: "Your queue isn't the problem. Your silence is." That stuck with me. A queue timeout isn't a bug to hide; it's a signal that something in your architecture is either misconfigured or genuinely broken. And if you can't see it, you can't fix it.

This matters more in Islamabad's growing startup ecosystem than people realize. We're building systems without the infrastructure maturity of San Francisco. We don't have teams dedicated to observability. When something fails quietly, it fails hard.

The Real Problem Isn't the Timeout

I used to think the issue was about tweaking timeout values. Add more retries, extend the window, adjust the backoff. But that's cargo cult thinking. The real problem is: what do you do when the queue can't deliver?

The article nails this: a dead-letter queue (DLQ) isn't permission to ignore the message. It's an investigation boundary. When a webhook times out three times, that failure should land in a queue you actively monitor, not disappear into logs nobody reads.

Here's what changed my approach: I stopped thinking of retries as "try harder" and started thinking of them as "gather better information." Each retry attempt should log why it failed, timeout, network error, 5xx response, not just count how many times it happened.

For reservation holds specifically, the timeout issue is catastrophic. A 15-minute hold that stays open because the expiry webhook timed out doesn't just annoy the customer. It distorts inventory, payment state, and balance calculations. That's not a retry problem; that's a design problem.

Choosing the Right Tool Matters More Than I Thought

I've used SQS, RabbitMQ, and BullMQ. Each one made sense for different reasons, but I was picking based on familiarity, not on what the problem actually needed.

The framework in the article is solid. If your team already lives in AWS, SQS with a DLQ pattern is straightforward. The tradeoff is vendor lock-in, but at least the observability is baked in. If you need routing logic, different webhook classes hitting different queues, RabbitMQ's exchange model pays for itself. The cost is operational depth: you own the broker, the persistence, the failover.

For me, BullMQ has been interesting because I'm already running Redis. But here's the uncomfortable truth: Redis isn't built for durable message persistence. It's a cache that can also hold a queue. That works until you need exactly-once delivery semantics, and suddenly you're writing recovery logic that rivals the business logic itself.

The article's point about Inngest and Temporal resonated with me. Those tools shine when you have multi-step workflows, not single delayed tasks. A reservation expiry is one bounded action: check the hold, close it if eligible. That doesn't need a workflow engine. It needs a reliable queue and clear terminal states.

What I'm Actually Doing Now

Here's the pattern I've settled on for production systems:

type ExpiryJob = {
 reservationId: string;
 expiresAt: string;
 idempotencyKey: string; // derived from reservationId + event time
};

type ExpiryResult = "acknowledged" | "retry" | "dead_letter";

async function processExpiry(
 job: ExpiryJob,
 attempt: number
): Promise<ExpiryResult> {
 const controller = new AbortController();
 const timeoutId = setTimeout(
 () => controller.abort(),
 2000 // 2 second hard timeout
 );

 try {
 // First: verify the hold is still valid
 const currentHold = await checkReservationState(job.reservationId);
 if (!currentHold || currentHold.expiresAt !== job.expiresAt) {
 // This hold was already processed or updated
 return "acknowledged";
 }

 // Second: attempt the expiry
 const response = await fetch(EXPIRY_ENDPOINT, {
 method: "POST",
 body: JSON.stringify({ reservationId: job.reservationId }),
 signal: controller.signal,
 timeout: 2000,
 });

 if (!response.ok) {
 throw new Error(`HTTP ${response.status}`);
 }

 return "acknowledged";
 } catch (error) {
 if (attempt < 3) {
 // Log context: which reservation, which attempt, what error
 console.log({
 reservationId: job.reservationId,
 attempt,
 error: error.message,
 timestamp: new Date().toISOString(),
 });
 return "retry";
 }
 return "dead_letter";
 } finally {
 clearTimeout(timeoutId);
 }
}

The critical pieces: an idempotency key (so duplicate messages don't double-expire), bounded timeout (no infinite hangs), explicit logging (so you can actually debug), and clear terminal states (acknowledged, retry, or dead-letter).

The Uncomfortable Question

Here's what I'm still wrestling with: if a reservation expiry message reaches the dead-letter queue, who actually redries it? In many teams, the DLQ becomes a graveyard. Nobody monitors it, nobody has a process to fix the underlying issue and replay.

My take: if you can't commit to monitoring and redriving the DLQ, you shouldn't use queues for critical operations. Use synchronous calls instead, accept the latency, and own the visibility you get. A slow system you can see beats a fast system you can't.

What's Your Approach?

Are you treating your dead-letter queues as investigation boundaries, or as failure receptacles? Drop a comment, I'm genuinely curious how other Islamabad-based teams handle this in production.

Source: This post was inspired by "Message Queue Timeout Issues in Delayed Webhook Delivery for Node.js" by Dev.to. Read the original article

Share this article

Written by Adil Sher

Full stack developer building high-traffic platforms, AI services, and custom web applications. Explore my portfolio, learn about my background, or get in touch.

Related Articles

When a Vulnerability Sits Hidden for Two Years, We All Have a Problem
Web Development Oct 23

When a Vulnerability Sits Hidden for Two Years, We All Have a Problem

I was debugging a memory leak in one of our microservices last month when a colleague mentioned the PHP security incident casually over coffee. "Yeah, they patched something critical," he said. I nodded and moved on. But then I actually read the details, and something clicked: th...

Why I Finally Stopped Shipping Code and Started Owning Problems
Web Development Oct 22

Why I Finally Stopped Shipping Code and Started Owning Problems

Last year, I handed off a major feature to a client and considered it "done." The code was merged, tests passed, deployment went smooth. Three weeks later, they called me at 2 AM because the feature was creating duplicate records under specific network conditions. I'd shipped cod...