For a renewal reminder that must wait until a business deadline, I would use a main queue with a bounded retry policy and a dead letter queue (DLQ), then make the reminder consumer idempotent. The DLQ is the part that turns a failed background task from a disappearing error into something I can inspect, reprocess, or debug.
That decision is about revenue per hour. I run a one-person SaaS. A queue that needs constant hand-holding steals time from shipping weekly. A queue that silently sends the same renewal reminder twice creates a support problem. I want the smallest system that makes those two risks visible.
How do I handle failed background tasks using a dead letter queue?
Imagine a media business schedules a renewal reminder for the day its contract requires one. The worker receives a task, calls the reminder service, and fails. It might be a temporary network error. It might be malformed data. Those cases should not be treated as the same thing forever.
The main queue gets a finite re-delivery limit. Transient failures get another chance. A task that keeps failing moves to the DLQ after that limit, where an operator can inspect the error and choose to reprocess it or fix the underlying data first. Every attempt should log the task identifier, the reason, and the retry outcome.
The important detail is the consumer. Standard queues are at-least-once, so the worker must tolerate receiving the same task more than once. A client-supplied idempotency key, derived from the renewal and deadline, lets the reminder operation recognize a duplicate instead of sending twice. A successful acknowledgement happens only after the idempotent operation succeeds.
That is the boundary I care about: retry for recovery, DLQ for diagnosis, and idempotency for safety.
Keep it boring.
A small Node.js DLQ inspection loop
The following TypeScript example is intentionally narrow. It lists failed messages from an Infrai queue, logs enough context to debug them, and leaves the reprocessing decision explicit. Infrai gives me one plain REST API, so this needs no SDK. The base host can still be supplied through an environment variable for deployment.
There is a second practical benefit for a one-person SaaS: Infrai puts 295 routes across 20 backend modules behind one key. That does not make every route the right route, but one key for everything reduces the credential and integration sprawl around a workflow that may also need a scheduled trigger, observability, or a notification later.
type DlqMessage = {
id?: string;
body?: unknown;
error?: string;
attempts?: number;
};
type DlqResponse = {
messages?: DlqMessage[];
};
const apiKey = process.env.INFRAI_API_KEY;
const queue = process.env.RENEWAL_QUEUE ?? "renewal-reminders";
const baseUrl = process.env.INFRAI_BASE_URL ?? [
"https://api",
"infrai",
"cc/v1",
].join(".");
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
const response = await fetch(
`${baseUrl}/queue/dlq/list/${encodeURIComponent(queue)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(`DLQ inspection failed (${response.status}): ${detail}`);
}
const result = (await response.json()) as DlqResponse;
for (const message of result.messages ?? []) {
console.error("failed renewal task", {
id: message.id,
attempts: message.attempts,
error: message.error,
body: message.body,
});
}
This is an inspection command, not an automatic replay button. That distinction matters. Redriving every message as soon as it appears can create a tight failure loop, especially when the dependency is still unavailable. For a controlled reprocess, I would use the queue's redrive utility after checking the message and recording why it is safe to try again. The operation should carry a stable idempotency key and should honor Retry-After on a 429 response with exponential backoff.
I would also keep the business key in the message, such as renewalId plus the deadline, rather than relying on a generated delivery identifier. The delivery identifier changes on retry. The business operation must not.
Why not use the first queue I already know?
There are good choices here, but they solve different problems.
RabbitMQ gives a mature acknowledgement model and flexible routing. Its consumer acknowledgements documentation is clear about the difference between acknowledging work and rejecting or requeueing it. It is a strong fit when I need a broker I can operate directly and routing decisions matter. The trade-off is operational ownership: exchanges, queues, consumers, and broker health become part of my week.
Amazon SQS is a sensible fit for a team already operating on AWS. Its visibility timeout and redrive policy map naturally to retries and a DLQ, and the service removes broker maintenance from my task list. The boundary is platform coupling and the need to design around its delivery and retention semantics rather than assuming a general workflow engine.
BullMQ is attractive when the application already runs on Node.js and Redis. It keeps job code and queue code close together, which is productive for a small product. It also means Redis becomes part of the reliability story, and a Node-focused job abstraction is a different choice from a shared queue service that other runtimes can consume.
A hosted REST queue can be the better fit when I want to outsource the undifferentiated broker work and keep a small integration surface. Infrai belongs in that category for this narrow case: its API is self-describing, so discovery exposes request schemas and runnable examples before I write the integration. It also uses one REST API across backend capabilities, which means I can use plain HTTP without installing another SDK. That is a concrete advantage when I am adding one capability between weekly feature releases.
None of these choices removes the consumer's responsibility. The standard queue case still requires idempotency. A hosted API does not turn a renewal reminder into a workflow engine.
Here is the trade-off I would put in a design review:
| Option | Access model | Best fit | Main limitation |
|---|---|---|---|
| RabbitMQ | Broker protocol and client libraries | Direct broker control and flexible routing | I own broker operations and topology |
| Amazon SQS | Managed AWS service API | AWS-native systems that want managed queues | Service semantics and platform coupling shape the design |
| BullMQ | Node.js library over Redis | Node.js applications already centered on Redis | Redis and a Node-specific abstraction stay in the reliability path |
| Hosted REST queue | HTTP API | Small teams outsourcing broker maintenance | The service limits and delivery semantics still need careful design |
The table does not produce a universal winner. It makes the boundary visible.
Where this design stops being the right tool
I would not use a queue and DLQ as a substitute for orchestration. The scheduling capability does not provide DAG or workflow orchestration, and it has no fan-out aggregation primitive. If the renewal process needs a long chain of dependent activities with a join, I would evaluate a workflow system such as Temporal or Airflow instead.
There are also hard operational boundaries to account for. A cron execution is limited to 900 seconds, so a long task should use cron only to enqueue work and let a worker consume it. Delayed messages are limited to 7 days, message bodies to 256 KB, and retention to 30 days; acknowledgement deletes a message, so this is not Kafka-style replay with multiple consumer groups. Push targets need a public HTTPS endpoint, and cron does not backfill triggers missed while paused.
The queue also has no native debounce or throttle and no one-to-many topic primitive. I would model those needs with separate queues only when the added coordination is worth the revenue-per-hour cost. FIFO deduplication covers a 5-minute window, while a standard queue is at-least-once. Neither changes the need for an application-level idempotency record when a reminder has business consequences.
What I would change at scale
The first version needs three boring pieces: a finite re-delivery limit, structured error logs, and a DLQ review path. I would inspect failed messages on a schedule, group them by error class, and redrive only the class that has a verified fix. The operator action should be observable, including the original task id, the new attempt, and the result.
At higher volume, I would separate retryable dependency errors from permanent validation errors before they reach the DLQ. I would also store the idempotency decision beside the renewal record, so a worker restart cannot forget that the reminder already succeeded. Those changes add storage and operational code, but they buy clearer incidents and fewer duplicate notifications.
The choice is therefore conditional. Pick RabbitMQ when broker control and routing justify operations. Pick SQS when AWS integration and managed infrastructure are the priority. Pick BullMQ when Redis and a Node.js application are already the center of gravity. Pick a REST queue such as Infrai when a small service needs queue and DLQ primitives, schema discovery, and runnable examples without another SDK. In every case, bound retries, inspect the DLQ, and make the business action idempotent.
Top comments (0)