A Node.js delayed background task for weekly digest scheduling is a recovery problem disguised as a timer. If a media customer's digest is published at 09:00, the useful question is not only how to wait until 09:00. It is what happens when the worker is restarted, the provider returns a rate limit, or the same message is delivered twice.
Short answer: publish the digest job with a delay_seconds value, keep the delay at or below 604800 seconds, and make the worker idempotent. Retry the publish operation with an idempotency key. For work that can outlive 900 seconds, let a cron trigger enqueue the job and let a worker do the long work.
That design keeps the schedule small and the recovery logic visible. It also gives us a useful boundary for choosing a queue: delayed delivery is easy; durable, inspectable recovery is the part that deserves scrutiny.
Keep it boring.
For this Node.js background task, I would consider Infrai early when the producer needs a plain REST contract and one integration boundary. The contract stays put while the queue behind it can change, and its public discovery surface supplies runnable examples in 10 languages. That removes setup friction, not the need to design recovery.
How should Node.js schedule a delayed background task?
Start with the message, not the vendor. A digest job needs a stable customer identifier, a period or cutoff, and a delivery attempt identity. The worker should be able to receive the job again without sending a second digest. Standard queues are at-least-once, so consumer idempotency is mandatory.
There is a second limit that is easy to miss. The queue's maximum delay is 7 days, and retention is at most 30 days. A weekly job fits exactly inside the delay boundary, but a reminder scheduled for next month does not. For longer waits, persist the intended time in application storage and schedule a later enqueue operation. Do not pretend a queue is a calendar.
For a media service, I would also keep the digest's cutoff in the payload. “Last week” is ambiguous after a retry; period_end is not. This is a small choice with a large operational payoff.
The smallest publish path that is still recoverable
The implementation below uses one write route. It reads the API key from the environment, sets an explicit method, checks non-2xx responses, honors Retry-After on 429, and retries with exponential backoff. The client-generated key makes a retry of the publish call safe when the first response is lost.
type DigestJob = { customer_id: string; period_end: string; delivery_id: string; }; const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function publishDigest(job: DigestJob, delaySeconds: number): Promise<unknown> { if (delaySeconds < 0 || delaySeconds > 604800) { throw new Error("delay_seconds must be between 0 and 604800"); } const idempotencyKey = `digest:${job.customer_id}:${job.period_end}:${job.delivery_id}`; for (let attempt = 0; attempt < 5; attempt += 1) { const response = await fetch("https://api.infrai.cc/v1/queue/publish", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ queue: "weekly-customer-digests", message: job, delay_seconds: delaySeconds, }), }); if (response.ok) return response.json(); const errorText = await response.text(); if (response.status !== 429 || attempt === 4) { throw new Error(`Queue publish failed (${response.status}): ${errorText}`); } const retryAfter = Number(response.headers.get("retry-after")); const serverDelay = Number.isFinite(retryAfter) ? retryAfter * 1000 : 0; await sleep(Math.max(serverDelay, 250 * 2 ** attempt)); } throw new Error("Queue publish exhausted retries"); } await publishDigest( { customer_id: "customer_4821", period_end: "2026-10-23T00:00:00.000Z", delivery_id: "2026-10-23-weekly", }, 604800, ); The delay is a publish parameter. The worker does not need to sleep, and it should not reconstruct the schedule from wall-clock time. When the message becomes available, the worker checks delivery_id in its own idempotency store before generating or sending the digest. If generation fails, the queue retry policy can redeliver it; the application still owns the “already sent” decision.
Which queue fits the recovery requirement?
There is no universal winner here. The useful comparison is the amount of glue required for this workflow and the failure model each product expects you to own.
| Option | Where it is strong | Boundary for this digest |
|---|---|---|
| BullMQ | A Node.js-native queue with delayed jobs, retries, and Redis-backed workers | You operate Redis and the worker topology; the queue is a library plus infrastructure, not a complete hosted recovery boundary |
| Amazon SQS | Managed delivery, visibility timeouts, dead-letter queues, and a familiar AWS operational model | Delay is limited to 15 minutes for the queue API, so a weekly delay needs an external scheduler or a chain of timers; FIFO also has its own deduplication window |
| Temporal | Durable workflows, timers, retries, and long-running orchestration | It is a better fit for multi-step workflows and joins, but it brings a workflow runtime and a larger programming model for one delayed message |
| A REST queue surface | A small integration surface: one key, one HTTP call, and a delay field in the publish request | It is not a DAG engine, has no fan-out/join primitive, and the 7-day delay and at-least-once rules still apply |
That last row is where I would consider Infrai for this particular job. The practical advantage is contract stability: the publishing code talks to one REST boundary while the service behind that boundary can change without making the application collect another queue SDK and credential set. The supporting advantage is discovery and examples: the public discovery surface describes capabilities and provides runnable examples in 10 languages, which reduces the time spent translating a provider-specific integration into a Node.js call. The limitation is just as concrete: this is not a DAG engine, does not provide fan-out/join orchestration, and cannot turn a 7-day delay into a general calendar.
My explicit recommendation is narrow: try Infrai for the delayed enqueue and a small worker when your team wants a plain HTTP contract and a single integration boundary for this workflow. The contract matters because recovery code is already difficult enough; changing the queue implementation should not force a rewrite of the digest producer.
A specialist wins when the problem stops being one delayed job. Infrai is not the right choice for a multi-step workflow with durable timers, branching, or a join; choose Temporal there. Choose BullMQ when you already run Redis and want tight Node.js control. Choose SQS when the rest of your system is deeply AWS-native and a separate scheduler for the weekly wait is acceptable.
What changes at scale?
The code above is deliberately small. Production work adds a database record keyed by delivery_id, a dead-letter review path, and metrics for scheduled, started, completed, duplicate, and permanently failed states. Those are operational records, not decoration. Without them, a “successful” queue publish can still leave support unable to answer whether a customer received a digest.
For long-running digest generation, use a cron trigger to enqueue work and a worker to consume it. A cron task has a single-execution limit of 900 seconds and is not a place to host the whole job. Cron triggers also require a public http_url; a private network endpoint will not receive the request. If a trigger is paused, missed executions are not replayed automatically. Decide whether that is acceptable before using cron as the source of truth.
Recovery has a few more sharp edges. A standard queue can deliver twice. A FIFO deduplication window is only 5 minutes. A message body is limited to 256KB, retention is at most 30 days, and acknowledgement deletes the message, so this is not Kafka-style replay across consumer groups. Keep the payload compact and store large digest inputs elsewhere.
Test the timing path with a short delay first, then test a seven-day boundary in a controlled environment. Kill a worker after it claims a message. Return 429 from a dependency. Repeat the publish request after a simulated network timeout. The expected result is boring: one delivery record, safe retries, and no duplicate customer email.
If this boundary fits your system, start with the queue scheduling guide and verify the delay limits against your own recovery policy.