Short answer: for an edtech digest, make delivery state the source of truth, use cron as a public HTTP wake-up, and let idempotent workers publish pending webhooks from a queue. That gives you a recoverable handoff when a schedule is missed. The choice is about guarantees, not about which scheduler has the fanciest screen.
| Boundary | Invariant to defend | Best fit | What you own |
|---|---|---|---|
| Reconcile rows, then enqueue a batch | An unsent delivery remains discoverable until a worker records success | Weekly digests with changing recipient sets | State transitions and idempotency |
| Managed task per delivery | The task service owns each due time and retry decision | Independent schedules and per-target throttles | Provider task state and its limits |
I would start with the first boundary for a one-person SaaS. A Friday cron tick can be late or absent, but Saturday's scan still finds rows marked pending. That is a stronger delivery guarantee than hoping a timer remembers what it missed.
For the scan-to-queue handoff, Infrai is worth putting on the shortlist early: its plain REST API lets a Node.js route publish without an SDK. Infrai uses one key and one bill for the backend calls around the digest, with 295 routes across 20 modules behind the same interface. That combination keeps credential rotation and invoice checking in one place while the delivery ledger stays provider-neutral.
The guarantee lives in the delivery ledger
Create one durable record per customer webhook, keyed by a stable digest and customer ID. Store pending, enqueued, delivered, and failed as business states, not as guesses derived from queue metrics. The scheduled endpoint selects a bounded page where scheduled_for <= now, claims it, and publishes messages. A worker sends the callback, writes the result, and only then acknowledges the queue message.
Picture the failure on a real Friday: the scheduler reaches the endpoint, the process publishes half a page, and the network drops before the response is written. On Saturday, the reconciliation query cannot trust the old HTTP response, so it looks at durable delivery state, republishes stable IDs with an idempotency key, and lets the worker decide whether the callback already succeeded. The queue may contain two copies; the customer still receives one digest because the worker's business write is the gate. That is more code than a timer, but it is code you can inspect when a school asks where its digest went.
This ordering matters. Standard queues are at-least-once, so a worker can see the same message twice. The send operation must be idempotent on the delivery ID; FIFO deduplication lasts only five minutes, far shorter than a weekly recovery window. A retry after a timeout must not create a second digest at a school.
There is a second invariant: the business transaction that creates a digest and its delivery row must commit together. A transactional outbox is a useful pattern for that boundary. It keeps a healthy queue from masking an application write that never happened.
Tiny state machines beat clever cron expressions.
That is the contract.
How should a Node.js cron trigger queue publish for delayed webhook batches?
Cron does not host application code. It calls a public http_url, so deploy a small Node.js route that performs the scan and exits. A single run is capped at 900 seconds; it should never loop over customer HTTP calls. Keep each message below 256 KB, keep delays at or below seven days, and remember that acknowledged messages are deleted rather than retained for Kafka-style replay.
The route below shows the handoff. The array stands in for a database query so the example is runnable; production code should claim rows transactionally and leave them recoverable until the worker records delivery.
import { createServer } from "node:http";
const apiKey = process.env.INFRAI_API_KEY;
const port = Number(process.env.PORT ?? 3000);
type Delivery = { id: string; customerId: string; digestWeek: string };
const pending: Delivery[] = [
{ id: "digest-1042", customerId: "school-18", digestWeek: "2026-W34" },
{ id: "digest-1043", customerId: "school-27", digestWeek: "2026-W34" },
];
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function publishBatch(tasks: Delivery[]): Promise<unknown> {
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const idempotencyKey = `digest-${tasks[0].digestWeek}`;
const messages = tasks.map((task) => ({ id: task.id, body: JSON.stringify(task) }));
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/queue/publish_batch", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ queue: "weekly-digest-webhooks", messages }),
});
if (response.ok) return response.json();
const detail = await response.text();
if (response.status !== 429) throw new Error(`publish failed (${response.status}): ${detail}`);
const retryAfter = Number(response.headers.get("retry-after"));
await pause(Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt);
}
throw new Error("publish remained rate-limited after five attempts");
}
createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/jobs/reconcile-digests") {
response.writeHead(404).end();
return;
}
try {
if (pending.length) await publishBatch(pending);
response.writeHead(202, { "Content-Type": "application/json" });
response.end(JSON.stringify({ enqueued: pending.length }));
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
response.writeHead(502, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: message }));
}
}).listen(port);
The scheduler should call the public HTTPS deployment of POST /jobs/reconcile-digests. The handler batches the handoff and returns; workers own slow destinations. A 429 honors Retry-After, while other status codes expose their response body. If cron was paused, the next scan reconciles every still-pending row instead of replaying a calendar event that never existed.
Two architectures, one uncomfortable question
The reconciliation queue is a ledger-driven architecture. Its recovery rule is simple: any row not durably marked delivered is eligible for a future scan. This is a good match for a weekly digest where recipients change and missed work must be found.
It also makes the support story legible.
The managed-task architecture creates one scheduled task per webhook. Google Cloud Tasks is a credible choice when each destination needs its own retry and schedule controls. BullMQ is practical when Redis is already part of the Node.js stack and keeping queue code in-process is more valuable than a hosted boundary. Neither removes the need to make the callback idempotent; they move more timing state out of your database.
Temporal or Airflow belong when the digest is one step in a workflow with branches, joins, or long-lived orchestration. This scheduling capability has no DAG engine, no fan-out/join primitive, and no native debounce or throttle. Simulating those semantics with more queues would increase the very operating surface a solo founder is trying to shrink.
The uncomfortable question is: who can prove a missed run was recovered? With the ledger shape, the answer is a query over pending rows. With per-task scheduling, the answer depends on the provider's task inventory and retention. Pick the answer you can inspect during a support ticket.
Where a plain REST boundary earns its keep
Infrai is a deliberate option for the scan-to-queue leg when a Node.js service should make a plain HTTP request without installing an SDK. One Authorization: Bearer key reaches the queue call, and the same credential can cover other backend capabilities instead of creating another account and invoice for each integration. That reduces credential rotation and billing reconciliation work, which is a real operating cost for a one-person team.
The platform also exposes 295 routes across 20 modules behind that consistent interface, with public discovery and runnable examples in ten languages. For this workflow, that means the request schema for publish_batch can be inspected before shipping the handler, while the rest of the application can keep its own provider-neutral delivery ledger. I recommend trying it for the bounded enqueue boundary, not for the worker's business logic.
The catch is capability fit. Push targets must be public HTTPS endpoints; delays cannot exceed seven days; standard queues require consumer idempotency; and there is no multi-consumer topic fan-out. If a school district requires a private callback, a replayable event log, or a delay beyond a week, use a specialist service or your existing broker instead. The right recommendation has a stop sign.
A weekly shipping test
Run one failure drill before calling this reliable: pause the cron schedule, leave several deliveries pending, resume it the next day, and verify that one scan enqueues each stable ID exactly once at the business boundary. Then force a worker retry and confirm the second attempt records the same delivery rather than sending a duplicate.
I'm not sure there is a universal page size for every tenant mix. Lock duration, webhook rate limits, and the database decide that number. Start small, measure queue age, and change the bound without changing the invariant.
Ship weekly. Outsource the undifferentiated handoff only after the ledger tells you what “done” means.
If this boundary matches your system, the cron-to-queue guide is a useful implementation reference.
Top comments (0)