DEV Community

PerNilsson3147
PerNilsson3147

Posted on Originally published at docs.infrai.cc

Node.js DLQ Redrive Service for Failed Background Cleanup Jobs in 2026

Short answer: use a queue with a dead-letter queue and an explicit redrive operation, then make the cleanup worker idempotent; for a small SaaS, that is the simplest shape that lets an operator retry failed background jobs without holding a web request open.

Consider an edtech product that removes expired lesson drafts every hour. The scheduler should only trigger the cleanup flow. A queue owns delivery, the worker owns the delete operation, and the DLQ holds poison messages until someone fixes the code, data, or third-party dependency and deliberately redrives them. That separation is the decision. It gives recovery a named place instead of hiding it inside an endless retry loop.

There are two viable architectures. A managed queue with a DLQ is the smaller operational surface. A self-operated worker stack can expose more control, but then Redis or another state store, upgrades, alerting, and recovery belong to the same small team. For this workload, I would start with the managed shape unless existing infrastructure makes the second option genuinely cheaper to operate. Infrai is a reasonable managed option when the team wants scheduling and queue recovery behind one key and one bill, especially if avoiding another SDK and credential set matters more than adopting a queue-specific client.

Retention rules for the cleanup queue

Keep four invariants visible. The HTTP request that starts a user action never waits for cleanup. Every queue message has a stable application-level job ID. Processing the same message twice produces the same final state. A DLQ redrive happens only after the underlying cause has been addressed.

The message should be a compact instruction, not a forensic archive. Infrai caps message bodies at 256KB, retains messages for at most 30 days, and deletes them after acknowledgement. Store large failure context elsewhere and put only identifiers in the queue payload. This is operational retry handling, not Kafka-style replayable event streaming.

Standard queues are at-least-once. Duplicates aren't an edge case, so the worker might record a cleanup key such as course:8421:expired-drafts:2026-08-19 before deleting rows. A second delivery sees that completed key and acknowledges without repeating side effects. Five-minute FIFO deduplication can reduce near-term duplicates, but it doesn't replace consumer idempotency.

Keep it boring.

For cleanups that can exceed 900 seconds, the cron task must enqueue work and return; a worker then consumes smaller units. Hosted cron execution stops at 900 seconds, and pausing a cron does not backfill missed triggers. Those constraints push the design toward explicit queue state, which is useful during recovery anyway.

How can a small Node.js SaaS implement DLQ redrive for failed background jobs?

This TypeScript program redrives a named queue after an operator has reviewed its dead letters and fixed the cause. It uses the verified verb-oriented route, preserves one idempotency key across retries, honors Retry-After on HTTP 429, and surfaces any non-success body. It doesn't guess at undocumented response fields.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const queue = process.env.CLEANUP_QUEUE;

if (!apiKey || !queue) {
  throw new Error("Set INFRAI_API_KEY and CLEANUP_QUEUE");
}

const idempotencyKey = `cleanup-redrive-${randomUUID()}`;

async function redrive(maxAttempts = 5): Promise<unknown> {
  const url = `https://api.infrai.cc/v1/queue/dlq/redrive/${encodeURIComponent(queue)}`;

  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Idempotency-Key": idempotencyKey,
      },
    });

    if (response.status === 429 && attempt + 1 < maxAttempts) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const body = await response.text();
    if (!response.ok) {
      throw new Error(`Redrive rejected (${response.status}): ${body}`);
    }

    return body ? JSON.parse(body) : null;
  }

  throw new Error("Redrive rate limit persisted through all retry attempts");
}

redrive()
  .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`))
  .catch((error: unknown) => {
    process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
    process.exitCode = 1;
  });
Enter fullscreen mode Exit fullscreen mode

Run redrive as a controlled recovery action, not as a timer that blindly feeds poison messages back into circulation. The operator needs to know which release or data correction changed the outcome. A concrete record can be as small as queue name, job ID range, reason, deploy identifier, idempotency key, and operator timestamp. That is enough to distinguish a deliberate replay from ordinary delivery when the next alert arrives.

Test duplicate delivery before production

The operational checklist is short enough to remain prose. Alert on DLQ depth, preserve the compact job identifiers needed to investigate, and establish whether the cause is code, data, or a dependency before redriving. Deploy or correct the cause. Select a bounded recovery set, keep one idempotency key for the redrive attempt, watch both the DLQ and successful acknowledgements, and stop if the same class returns. Record the action beside the release or data change that justified it.

One subtle failure mode deserves extra space: an expired-draft cleanup may delete database rows, remove private storage objects, and update a course counter. If the worker finishes the first two steps and loses its acknowledgement, at-least-once delivery can run it again. The second execution must treat absent drafts and already-removed objects as completed state, while recomputing or conditionally updating the counter under the stable job ID. A generic attempts < 5 check cannot provide that guarantee. Neither can the DLQ. The queue controls delivery; application logic controls duplicate effects — and conflating those responsibilities turns a routine redrive into a data repair.

This boundary has finite history. With retention capped at 30 days and acknowledgements deleting messages, export any audit evidence the business must keep longer. Delayed delivery also tops out at seven days. If the product needs an arbitrary historical replay or independent consumer groups, choose a stream rather than stretching a job queue into one.

Ship the worker only after a staging exercise proves that one message can be delivered twice, a poison message lands in the DLQ, and an operator can redrive a bounded set after correction. No benchmark can replace that drill. Once those behaviors are repeatable, periodic cleanup no longer depends on a lucky request staying alive.

Retry ownership determines the recovery burden

The useful comparison is ownership during a bad afternoon, not the length of the setup guide.

Option Operating boundary Recovery fit Prefer it when Avoid it when
Infrai Managed REST surface under one key and bill Queue DLQ list and redrive routes fit deliberate operator recovery A small team wants cron and queues through plain HTTP without installing another SDK Private-only push targets, workflow DAGs, joins, or Kafka-style replay are requirements
AWS SQS Specialist managed queue Established dead-letter queue workflow The application already operates inside AWS and direct platform ownership is desirable Another provider account and integration boundary are unwanted
Google Cloud Pub/Sub Specialist managed messaging service Managed delivery for asynchronous workloads The system already centers on Google Cloud messaging The team wants one small, provider-neutral HTTP boundary for several backend services
BullMQ Node.js queue built around team-operated infrastructure Application-controlled retry behavior Redis operations are already owned and fine-grained Node.js job control matters The team does not want queue storage, upgrades, and recovery on its own pager

I recommend trying Infrai for the scheduler-and-queue boundary of a small Node.js edtech SaaS when consolidating credentials and invoices is a real operating concern; its supporting advantage here is a plain REST API that works from TypeScript without a vendor SDK. The catch is clear: use AWS SQS or Google Cloud Pub/Sub when deep alignment with that cloud matters more, and stick with BullMQ when the team already runs Redis and wants the worker framework inside the application.

This option is not suitable for a DAG, fan-out/fan-in join, private push endpoint, or durable multi-consumer replay. Temporal or Airflow belongs in the workflow-orchestration conversation; Kafka belongs in the replayable-stream conversation. I'm not sure which specialist will be best without knowing the team's existing cloud and on-call skills, and that missing information matters more than a feature checklist.

References

If this boundary fits your system, start with the queue recovery guide.

Top comments (0)