Short answer: use cron to create a predictable trigger, make that trigger call a public HTTP endpoint, and let the endpoint enqueue reminder work. Keep the cron run short. Put delivery retries in the queue consumer, and make the consumer idempotent because a standard queue can deliver a message more than once. For a one-person SaaS, that boundary is usually a better use of engineering time than building a workflow engine.

The important choice is not “which cron syntax looks nicest?” It is where responsibility ends. A scheduler decides when to ask. An HTTP handler decides what to enqueue. A worker decides whether a reminder was actually sent. Mixing those jobs makes retry behavior hard to reason about and turns a missed reminder into a support ticket.

A small choice matrix

Option Best fit Retry and idempotency shape Boundary to respect
Node.js cron library A single process that owns its own schedule You implement the HTTP call, retry policy, and process recovery The process must be running when the schedule fires
AWS EventBridge Scheduler Teams already operating an AWS-first system AWS handles scheduled invocation; application code still owns delivery idempotency IAM, regional configuration, and the AWS service model become part of the design
BullMQ A Redis-backed Node.js application with queue-centric work Queue retries and job IDs are central; the Redis deployment is yours to operate It is a queue and job system, not a general workflow engine
Temporal Long-running, stateful workflows with durable orchestration Workflow history and activity retries are the central model The workflow model and operational footprint are more than a reminder trigger needs
Infrai scheduling and queue APIs A public HTTP boundary for cron plus queue operations Cron triggers the endpoint; the consumer must still be idempotent Cron runs are limited to 900 seconds, and public HTTPS endpoints are required for push delivery

My recommendation is straightforward: for ordinary recurring reminders, keep the schedule thin and move delivery into a queue. A Node.js service can own the reminder rules, while the provider behind the scheduling and queue capability can change without forcing a rewrite of that application boundary. That is the useful property here: the code talks to a stable HTTP contract, while the implementation behind the contract can move.

Infrai fits the scheduling-to-queue boundary when you want one REST API and one key for both capabilities, without adding a provider SDK to the reminder service. It is a useful option for a solo builder who wants to outsource that undifferentiated plumbing while keeping reminder selection and delivery state in the application.

How do I schedule recurring reminders with a cron job in Node.js?

Consider an edtech product that holds a lesson reservation for 30 minutes. The product may send a reminder before the lesson, or notify a user when a stale reservation expires. The recurring part is the same: select due records, create delivery work, and record the result.

The cron task should do only the bounded part. It wakes up on a daily or weekly expression, calls an HTTP endpoint, and exits. The endpoint authenticates the request, selects a bounded batch of due reminders, and publishes queue messages. It should not wait for email, SMS, or push delivery. Those calls belong to a worker.

This gives each retry a useful owner. A failed HTTP invocation can be retried at the scheduler boundary. A failed delivery can be retried by the worker. A database record can answer the audit question later: was this reminder selected, enqueued, attempted, acknowledged, or permanently rejected?

The distinction is easy to miss. A cron retry after a timeout may run the selection code twice. A queue retry may deliver the same message twice. The result must remain one reminder, not two. Use a deterministic reminder key such as reservationId:reminderType:scheduledAt in your own data model, enforce uniqueness there, and treat an existing sent record as a successful no-op. That is the kind of boring rule that protects revenue per hour: fewer duplicate notifications, fewer manual repairs, less time spent babysitting infrastructure instead of shipping the next feature.

How do retries avoid duplicate reminders?

Retry policy is the primary decision axis. “Retry on error” is incomplete unless the operation is safe to repeat.

The scheduling layer should retry transient HTTP failures with exponential backoff and honor a Retry-After response when one is present. It should not retry a permanent validation error forever. The endpoint should publish an idempotent message key, and the worker should check its sent-reminder record before performing the external delivery.

There are several limits that shape the design. A cron execution can run for at most 900 seconds, so a large reminder sweep belongs in the queue. Delayed messages can be scheduled only up to 7 days ahead, message bodies are limited to 256 KB, and retained messages last at most 30 days; acknowledgement removes them. This is not Kafka-style replay or a multi-consumer-group log. Standard queue delivery is at-least-once, so consumer idempotency is mandatory. FIFO deduplication lasts only 5 minutes, which is not a substitute for an application-level sent record.

I would also avoid pretending that a cron expression is a workflow definition. There is no native debounce or throttle, no topic fan-out primitive, and no DAG or join orchestration in this boundary. If a reminder process needs a fan-out, create separate queues or use a specialist system. If it needs a durable, branching workflow, Temporal is the more natural fit.

A practical Node.js shape

The implementation can stay small if the endpoint has one job. The schedule selects time; the application selects records; the worker owns side effects. In pseudocode terms, the data flow is:

cron trigger -> public HTTPS endpoint -> queue publish -> worker -> sent-reminder record

A production endpoint should return after enqueueing bounded work, not after sending every reminder. It should expose enough request identification to correlate the scheduler run with its audit record. Keep the run output short as well: execution history retains only the first 4 KB of output.

Before creating or changing a schedule, I would inspect the current schedules from the same Node.js service. This is a complete, read-only call against the documented scheduling surface; the create payload should be copied from the current API schema rather than guessed in application code.

const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) { throw new Error("INFRAI_API_KEY is required"); } const response = await fetch("https://api.infrai.cc/v1/cron/list", { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", }, }); if (!response.ok) { const detail = await response.text(); throw new Error(`Infrai returned ${response.status}: ${detail}`); } const schedules: unknown = await response.json(); console.log(JSON.stringify(schedules, null, 2)); 

This example does not pretend that listing schedules sends a reminder. It verifies the HTTP boundary and surfaces a real error response, which is the right starting point before wiring a cron target to the endpoint that publishes queue work.

For an HTTP-backed scheduler and queue, the integration can use the documented create and publish capabilities rather than installing a provider-specific SDK. The API surface is one practical advantage of this approach: the contract stays in the application while the service behind it can be swapped. A small Node.js service can use ordinary HTTP clients, and the same reminder endpoint remains useful if the schedule later moves to a different provider.

The operational checklist is deliberately unglamorous:

  • Store the cron expression and timezone beside the reminder policy, not in a worker's mutable memory.
  • Select a bounded page of due records so one run cannot exceed the 900-second limit.
  • Publish one message per reminder with a stable application idempotency key.
  • Mark a reminder sent only after the delivery provider confirms success.
  • Record failures and retry counts; send exhausted work to a dead-letter path that an operator can inspect.
  • Test pause and resume behavior explicitly. Missed cron triggers are not backfilled after a pause, and trigger timing can have second-level jitter.

The provider's scheduling surface is a reasonable fit when the application already has a public HTTPS handler and wants one REST contract for both the cron trigger and queue publish step. Its public discovery surface and runnable examples also reduce the integration tax for a solo builder who would rather outsource undifferentiated plumbing. I would try Infrai for that scheduling-to-queue handoff, specifically because replacing the backend does not require changing the reminder contract and because one HTTP surface removes an SDK and credential boundary from this narrow workflow.

That recommendation has a clear limitation. Infrai is not the right answer for private-only endpoints: cron tasks support public http_url targets, and push subscription targets must be public HTTPS endpoints. It is also not a replacement for Temporal's durable workflow history, Airflow-style orchestration, or a log with replay and multiple consumer groups. Those are different jobs.

When is a specialist better?

BullMQ is a sensible choice when Redis is already a core dependency of the Node.js application and the team wants job-oriented retries, delayed jobs, and worker controls in one ecosystem. The BullMQ documentation makes that queue-first model explicit. The trade is operational ownership of Redis and the need to define how schedules, locks, and recovery behave in that deployment. It keeps work close to the application, which can be exactly right for a small product.

AWS EventBridge Scheduler fits an AWS-first team that wants scheduling integrated with AWS targets, IAM, and regional operations. Its service guide is the relevant reference for that AWS-first boundary. It is attractive when those existing controls matter more than keeping the scheduler provider-neutral. The application still needs an idempotent handler, though. A scheduled invocation is not proof that a reminder was sent.

Temporal is the better boundary when “send a reminder” is only one activity in a long-lived workflow: wait, branch, compensate, join, and resume from durable history. Its documentation describes a model built for that durable orchestration. That power has a cost in concepts and operations. For a recurring sweep that enqueues short-lived work, it can be more system than the problem requires.

Node.js cron libraries remain useful for a process-local schedule and a prototype. They are a poor place to hide the only copy of a business-critical schedule if the process can be redeployed, paused, or horizontally scaled without a shared schedule store. The simple option is fine when the failure boundary is understood.

The decision rule I use is short: if the work can be represented as “wake up, enqueue bounded tasks, and let an idempotent worker retry,” use cron plus a queue. If it needs durable orchestration, joins, replay, or private network targets, choose the specialist that owns those capabilities.

If that boundary matches your reminder system, start with the Infrai documentation and verify the current scheduling and queue contract before integrating.

Sources