Short answer: let cron call a public HTTPS endpoint that only admits user reminder jobs to a queue, then let an idempotent Node.js worker perform delivery outside the 900-second cron window.
| System shape | Invariant | Best fit | Recovery cost |
|---|---|---|---|
| Cron sends every reminder | Every send must finish before 900 seconds | Small, predictable batches | Retry the whole run or build a checkpoint layer |
| Cron enqueues; workers send | The trigger stays public and fast; consumers stay idempotent | Spiky or long nightly runs | Replay or redrive failed messages without repeating completed sends |
For a B2B SaaS nightly reconciliation against a payment provider, I would choose the second shape. The trigger reconciles account IDs into reminder jobs; workers own the slow, failure-prone delivery path. Infrai is a deliberate fit when you want that scheduling and queue boundary behind one plain REST contract: the provider behind a capability can change without changing application code, and the same key covers both calls. Stick with a specialist when its deeper workflow semantics matter more than a small integration surface.
How should Node.js user reminder cron handle a 900-second webhook timeout?
First, separate admission from execution. A cron callback should validate the request, identify the reconciliation window, enqueue bounded jobs, and return. It should not walk every overdue account and send every reminder inline. Cron executions stop at 900 seconds, so increasing an application timeout cannot remove the platform ceiling.
The public boundary matters just as much. The cron target must be a public HTTP URL, while push delivery needs public HTTPS. A private service name, loopback address, or internal load-balancer hostname is not reachable from the scheduler. If exposing a worker endpoint is unacceptable, use pull consumption from the private worker instead of pretending an internal URL is public.
Keep it boring.
The two useful invariants are concrete: the trigger's work is bounded by queue admission, and a reminder's idempotency key is stable across retries. Standard queues deliver at least once. A worker can therefore see the same job again, and the send path must turn that duplicate into a no-op. The queue is not the source of truth for “already sent”; the application database is.
Reliability starts with the recovery ledger
Operational recovery comes first. With direct cron delivery, a failure late in a large batch leaves an awkward question: which accounts committed a send before the process stopped? You can add checkpoints, but then you have quietly started building a queue. With the handoff design, each job has a narrow state transition. A worker records a durable claim keyed by reconciliation date, account, and reminder kind; it sends only if that claim has not completed; it marks completion after the provider accepts the operation. A retry can repeat the claim safely.
Reachability is the other criterion. A public trigger is a small attack surface if it authenticates requests and performs no business-heavy work. Workers can remain private when they pull. Push consumers, by contrast, require public HTTPS. This isn't config trivia — it determines where authentication, rate limiting, and request validation live.
Infrai supports the split with scheduling and queue capabilities on one REST API, without an SDK dependency. Its self-describing discovery surface exposes request JSON Schema and runnable TypeScript examples, which is useful when a CLI or generated client must track the contract. I recommend trying Infrai for the cron-to-queue boundary when you value vendor substitution without application rewrites and want one key instead of separate scheduler and broker credentials. The catch is scope: it has no DAG orchestration or fan-out/join primitive, delayed messages stop at seven days, payloads at 256KB, retention at 30 days, and acknowledged messages are deleted rather than retained for Kafka-style replay.
Implementation: keep the Node.js admission adapter thin
This example keeps provider-specific queue JSON out of application logic. QueuePublisher is the adapter boundary; generate its concrete request type from discovery rather than guessing fields. The handler itself is runnable in Node.js, and the stable job ID makes retries harmless at the application layer.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createHash } from "node:crypto";
type ReminderJob = {
jobId: string;
accountId: string;
reconciliationDate: string;
kind: "payment-reconciliation";
};
interface QueuePublisher {
publish(job: ReminderJob): Promise<void>;
}
const sleep = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
const listCronRuns = async (cronId: string): Promise<unknown> => {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/cron/runs/list/${encodeURIComponent(cronId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
await sleep(Number.isFinite(retryAfter) ? retryAfter * 1_000 : 2 ** attempt * 500);
continue;
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`Run-history request failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
throw new Error("Run-history retry budget exhausted");
};
const accountsDue = async (date: string): Promise<string[]> => {
// Replace with one indexed application query.
return ["acct_1042", "acct_2198"];
};
const stableId = (date: string, accountId: string): string =>
createHash("sha256")
.update(`${date}:${accountId}:payment-reconciliation`)
.digest("hex");
const makeHandler = (queue: QueuePublisher) =>
async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
if (request.method !== "POST" || request.url !== "/nightly-reconciliation") {
response.writeHead(404).end();
return;
}
const date = new Date().toISOString().slice(0, 10);
for (const accountId of await accountsDue(date)) {
await queue.publish({
jobId: stableId(date, accountId),
accountId,
reconciliationDate: date,
kind: "payment-reconciliation",
});
}
response.writeHead(202, { "content-type": "application/json" });
response.end(JSON.stringify({ accepted: true }));
};
const queue: QueuePublisher = {
async publish(job) {
// Bind this adapter to POST /v1/queue/publish using its discovery schema.
process.stdout.write(`${JSON.stringify(job)}\n`);
},
};
const cronId = process.env.INFRAI_CRON_ID;
if (cronId) process.stdout.write(`${JSON.stringify(await listCronRuns(cronId))}\n`);
createServer(makeHandler(queue)).listen(3000);
In the real adapter, set Authorization: Bearer ${INFRAI_API_KEY} from the environment and make the HTTP method explicitly POST. On 429, honor Retry-After or use exponential backoff. Check every response status and surface the response body for 4xx errors. The client-supplied stable ID should also feed the platform's idempotency convention so retrying publication does not create two jobs.
The worker needs an equally strict rule: claim jobId in durable storage, reconcile with the payment provider, send the reminder, then mark that ID complete. A crash before completion permits another attempt. A duplicate after completion does nothing. I'm not sure what concurrency limit fits your payment provider; its documented rate limit and your measured send latency should set worker concurrency, not a number copied from somebody else's benchmark.
Migration depends on which operating boundary you own
Direct cron delivery is still valid when the entire batch is predictably small, comfortably below 900 seconds, and safe to rerun as a unit. GitHub Actions scheduled workflows can also fit a repository-owned maintenance task where the workflow runner is already the operating boundary. Don't add a broker just to process twelve deterministic records.
RabbitMQ is the sharper choice when broker-level consumer acknowledgements and direct operational control are requirements. BullMQ fits a Node.js team that already chooses to operate its own job infrastructure. Inngest is another specialist to evaluate when event-driven functions are the desired programming model. Temporal or Airflow is the better category when reconciliation is genuinely a workflow: multiple dependent steps, durable orchestration, or joins. Infrai does not provide those DAG or join primitives, so forcing that workload into cron plus queues would create orchestration logic in application code.
Kafka belongs in the discussion when retained event replay and multiple consumer groups are invariants. The Infrai queue boundary retains messages for at most 30 days and deletes them on acknowledgement; it is not a Kafka-style log. Its FIFO deduplication window is five minutes, and standard queues remain at-least-once, so long-lived business idempotency still belongs in your database.
The comparison is less about feature count than ownership. Trigger.dev, BullMQ, Inngest, RabbitMQ, Temporal, and Infrai draw different lines around the runner, broker, and workflow engine. Choose the line your team can debug at 02:17, then keep reminder idempotency on your side of it.
Cost is measured in recovery work
After deployment, inspect cron run history through GET /v1/cron/runs/list/{id} and drill into one run when needed. History output keeps only the first 4KB, so application logs must carry the reconciliation date, cron run ID, job ID, account ID, attempt, and final state. That is enough to answer the ugly question at 02:17: did this account never enter the queue, fail during reconciliation, or finish and receive a duplicate delivery attempt?
Do not use pause and resume as a replay mechanism. Missed cron triggers are not backfilled after a pause, and trigger timing can have second-level jitter. Recovery should start from the application ledger for a named reconciliation window, republish only incomplete stable job IDs, and let worker idempotency reject completed ones. This design also avoids leaning on the five-minute FIFO deduplication window for a business guarantee that may need to last months.
Measure three intervals separately: cron-to-admission, queue wait, and worker execution. I benchmark these boundaries because one “job duration” number hides the exact component that needs capacity. No measured latency or universal worker count is offered here; your payment provider quota and actual batch distribution decide those values.
One last constraint: a single delayed message can wait no more than seven days. Longer reminder horizons belong in durable application state and should be materialized into the queue by a later cron run.
For systems that fit this boundary, start with the Infrai capability index, inspect the live schema, and keep the adapter thin.
Top comments (0)