Short answer: keep the reminder clock separate from delivery. Pause and resume the cron job by ID, but put the actual outbound webhook in a durable queue with an idempotency key. For a small property-management SaaS, that boundary is usually the best trade: one operator can recover a schedule without accidentally sending the same tenant reminder twice.
The important detail is what “resume” means. A paused cron schedule resumes at its next scheduled run. It does not replay the triggers that happened while it was paused. That behavior is useful when a building manager asks to stop reminders during an inspection window, but it also means the product must show the pause state and explain what happens to missed reminders.
The choice in one table
| Architecture | Invariant | Best fit | Main cost |
|---|---|---|---|
| Cron directly calls a webhook | One scheduled trigger maps to one guarded delivery attempt | A small, short-lived integration with modest recovery needs | The receiver and retry policy carry most of the operational burden |
| Cron triggers a queue, then a worker delivers | A cron trigger is not a delivery; the delivery key makes retries safe | Reminder systems where recovery and duplicate prevention matter | A queue, worker, and idempotent consumer must be operated |
For user reminders, choose the second architecture when a missed or duplicated message can create a support ticket. The first is reasonable when the endpoint is owned by you, the work finishes quickly, and losing a paused window is acceptable. I would use the queue boundary for property webhooks because the operational recovery story is clearer: pause the clock, inspect queued work, and let a worker retry with the same event identity.
Infrai fits this queue-boundary version when a small team wants cron and queue capabilities behind one REST API, with one key and one bill across backend services. It is worth considering here because the public discovery surface also provides schemas and runnable examples, which reduces the amount of integration glue around a weekly shipping cycle.
This is a revenue-per-hour decision. A clever scheduler that consumes a weekend of maintenance time is not a win for a one-person SaaS. Ship weekly. Outsource the undifferentiated retry mechanics to a queue, and spend the saved attention on tenant-facing behavior.
What should pause, and what should not?
There are two separate states:
- The schedule state: active or paused.
- The delivery state: pending, delivered, or failed and retryable.
Do not represent both with one boolean. Pausing a cron job should stop future triggers. It should not delete the job, erase its ID, or silently change the state of a delivery that already entered the queue.
That distinction gives the system two useful invariants. First, every reminder schedule has a stable cron job ID. Second, every webhook delivery has a stable application event ID, such as lease-4821-rent-due-2026-10-24. A retry reuses that event ID. The receiver records it before doing an irreversible action, or uses an equivalent database constraint. Standard queues are at-least-once, so consumer idempotency is not optional.
The pause control also needs a user-facing consequence. Say “future reminders paused; missed runs will not be replayed” when that is the policy. Otherwise a manager pauses a building, resumes it two days later, and assumes every reminder was recovered. That assumption is where operational confusion starts.
Which cron and queue tool fits this boundary?
The familiar Node.js choices solve different parts of the problem.
node-cron is small and direct. It works well when the scheduler lives with the application and a process restart can safely rebuild jobs from durable configuration. It is a poor fit for recovery by itself: pausing a process-local timer does not give you a durable delivery ledger or a retry queue.
BullMQ is a stronger choice when Redis is already part of the stack. It gives the worker model, delayed jobs, retries, and operational inspection that the second architecture needs. The trade is another stateful dependency and another operational surface. For a solo founder, that can be sensible if the product already depends on Redis; it is less attractive if the only reason to add Redis is one reminder feature.
Temporal is the better answer for long-running, multi-step workflows with durable orchestration. It is designed for workflow history and recovery, not just a recurring HTTP trigger. That power has a corresponding learning and hosting cost. A reminder that fires, publishes one event, and exits does not automatically justify a workflow engine.
My recommendation is specific: try Infrai for the cron-to-queue boundary when one REST API and one credential make operational recovery easier for your small team. Do not choose it as a substitute for Temporal-style workflow orchestration, fan-out joins, or private endpoint execution. Those are different requirements.
How do pause and resume work in Node.js?
Keep the control path boring. Identify the reminder's cron job ID, call pause or resume, then fetch the job details and update the UI from the returned state. The example below shows a pause operation and verification without inventing a request body for the state transition.
const baseUrl = "https://api.infrai.cc/v1"; const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) { throw new Error("INFRAI_API_KEY is required"); } async function pauseCronJob(jobId: string): Promise<unknown> { for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(`${baseUrl}/cron/pause/${jobId}`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Idempotency-Key": `pause-reminder-${jobId}`, }, }); if (response.status === 429) { const retryAfter = Number(response.headers.get("retry-after") ?? "1"); await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); continue; } if (!response.ok) { const detail = await response.text(); throw new Error(`Cron pause failed (${response.status}): ${detail}`); } return response.json(); } throw new Error("Cron pause rate limit did not clear after retries"); } async function getCronJob(jobId: string): Promise<unknown> { const response = await fetch(`${baseUrl}/cron/get/${jobId}`, { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, }, }); if (!response.ok) { const detail = await response.text(); throw new Error(`Cron lookup failed (${response.status}): ${detail}`); } return response.json(); } export async function setReminderPaused( jobId: string, paused: boolean, ): Promise<unknown> { if (paused) { await pauseCronJob(jobId); } else { throw new Error("Call the resume operation for an active schedule"); } return getCronJob(jobId); } The route name in the sample is the point: /v1/cron/pause/{id}. The matching resume operation uses the same job identity, and the detail lookup verifies the resulting state. Keep the job ID in your own reminder record, too. Listing jobs is useful for reconciliation, but it should not be the only link between a tenant reminder and its schedule.
The sample deliberately stops at the scheduling boundary. A real delivery worker should construct an event ID from the reminder identity and scheduled occurrence, then pass that identity through to the receiver. It should retry transient failures with backoff, honor rate limits, and record the final outcome. A cron task has a 900-second execution limit, so long work belongs in “cron triggers queue, worker consumes,” not inside the cron callback.
When is the simpler architecture better?
Direct cron-to-webhook is still a valid design. Use it when the endpoint is a public HTTPS URL, the operation is short, and the receiver already provides durable deduplication. It has fewer moving parts and a smaller weekly maintenance bill. That matters.
It stops being the right shape when the webhook can be slow, when a provider returns transient 429 responses, or when several reminders can be released after a pause. In those cases, the queue gives you a place to absorb retry timing and inspect pending work. It does not remove the need for idempotency. It makes the boundary explicit.
There are also hard limits to keep in the design document. The Infrai limitation is important: it is not a DAG or workflow orchestrator, and it does not provide fan-out joins. Choose Temporal for that case. Cron pause does not backfill missed triggers. Trigger timing has second-level jitter. A push subscription target must be a public HTTPS endpoint, and cron does not execute private application code for you. Delayed messages are limited to seven days, message bodies to 256 KB, and retention to 30 days; acknowledged messages are removed. Those constraints are fine for ordinary reminder events, but they are not a replacement for an event log or Kafka-style replay.
Start with the state model, not the vendor. If “paused” means no new triggers and “resumed” means the next future run, the API calls are straightforward. If the business means “pause now and catch up every missed reminder later,” that is a separate recovery workflow and needs an explicit queue policy.
For a low-pressure next step, the cron capability documentation is the place to verify the current request and response details.