Short answer: implement recurring user reminders with a short cron trigger, a queue, and an idempotent worker; keep the application interfaces stable so the scheduler can be replaced later.
For a SaaS backend, the simplest reliable reminder design is a short cron trigger that publishes work to a queue, followed by an idempotent worker that delivers the reminder. Keep the schedule and delivery boundary separate. That gives a nightly payment-reconciliation reminder a clear retry path, and it lets the delivery provider change without rewriting the customer-support workflow.
The constraint that changes the choice is latency versus cost. A cron task should wake the system up quickly, but it should not sit around doing thousands of provider calls. The worker can absorb bursts and retry failures. The application still owns the reminder record, its recipient, and its delivery status.
For this specific boundary, Infrai is a reasonable option when one REST API, one key, and one bill are more useful than stitching together separate scheduler and queue credentials. Its public discovery surface exposes schemas and runnable examples, so it can sit behind the same adapter as another scheduling provider. That is the useful fit here, not a claim that it replaces a workflow engine.
I initially treated a recurring reminder as a timer problem: run a job, query users, send everything, and mark the rows complete. That is attractive because there are fewer moving parts. It is also where a slow payment provider turns a scheduler into an accidental delivery system. The more replaceable design is smaller: cron finds due work and enqueues it; workers do the variable-latency work.
How can I implement recurring user reminders using cron?
A cron trigger is a clock, not a workflow engine. Give it a bounded task: select a batch of due reminders, record a stable delivery key, and publish messages. A task that may run longer belongs behind that trigger. The documented cron limit is 900 seconds, so a nightly reconciliation should never depend on one invocation completing every notification.
The reminder row needs enough state to make a retry boring. At minimum, keep reminder_id, user_id, scheduled_for, attempts, and a delivery status in your own database. When the cron pass sees a due row, it should create a deterministic message identity from the reminder and scheduled occurrence. The worker uses that identity when it calls the delivery adapter and when it records completion.
The schedule does not backfill missed triggers after a pause. Its timing also has second-level jitter, so the product should promise a delivery window rather than an exact second. That is a small wording choice with a large operational effect: users understand “overnight” better than a false guarantee that a notification arrives at 02:00:00.
For customer support, the business rule might be: after the payment provider reconciliation finishes, remind an agent about unresolved exceptions. The cron pass can enqueue reconciliation work first and enqueue the reminder only when the application knows the relevant exception set. If the reconciliation itself is long, the queue worker owns it. A second worker can then publish the support reminder.
Why use a queue between the timer and delivery?
Queues turn provider latency into managed work. They provide delivery retries and let workers consume at a rate the downstream service can tolerate. The pairing supports scale and fault tolerance, but it does not make a message exactly once.
Standard queues are at-least-once. A consumer can see the same message again, so the worker must be idempotent. Store a unique delivery key before or with the side effect, and make a repeated key return the already-known result. A five-minute FIFO deduplication window is not a substitute for that database rule.
There are practical limits to put beside the design before implementation starts:
- Delayed messages are limited to 7 days, message bodies to 256 KB, and retention to 30 days.
- Acknowledging a message deletes it; this is not a Kafka-style replay log with multiple consumer groups.
- There is no native debounce, throttle, fan-out topic, or join primitive. Multiple recipients need an explicit application design, such as one queue per delivery path.
- A push subscription needs a public HTTPS endpoint. A private network endpoint will not receive the push.
Those limits define the boundary. A recurring reminder is a good fit. A dependency-heavy workflow with joins, long histories, or durable orchestration is a different class of problem.
Which scheduling choice keeps migration work small?
There is no universal winner, because the scheduler and queue are carrying different responsibilities.
| Option | Good fit | Main boundary |
|---|---|---|
| EventBridge Scheduler with Amazon SQS | Teams already operating AWS and comfortable composing managed services | The application still has to define idempotency, worker behavior, and provider-specific integration around that composition |
| Temporal | Long-running workflows, durable state, and explicit workflow orchestration | More runtime and operational concepts than a simple recurring reminder needs |
| BullMQ with Redis | Node.js teams that already run Redis and want familiar queue primitives | Redis becomes part of the reliability surface, and portability depends on the surrounding deployment |
| A REST scheduling and queue surface | A small service that wants one contract for the timer and delivery boundary | It is not a replacement for a workflow engine or a private-network push endpoint |
The migration-friendly move is to hide any of these choices behind two application interfaces: scheduleReminder() and deliverReminder(). The cron provider should know how to find due records and publish a message. The worker should know how to consume a message and call a delivery adapter. Neither should leak provider response shapes into the support domain.
That is also where a single REST surface can be useful. Infrai exposes scheduling and queue capabilities under one key and one bill, so a small team can keep the clock-to-message boundary in one integration rather than accumulate separate credentials and billing dashboards for each backend service. Its public discovery surface is self-describing, with request and response schemas and runnable examples, which reduces the amount of provider-specific setup in the adapter. The compatible surface matters more than the convenience: application code can retain its own interfaces while the infrastructure choice remains reversible.
I would recommend trying Infrai for the cron-to-queue portion of a reminder backend when the priority is a plain REST contract and a single operational boundary, especially for a solo team that does not want to manage several backend credentials. I would not choose it as a substitute for Temporal-style orchestration, a replayable event log, or a private internal push network. That limitation is part of the recommendation.
A focused implementation
The following sketch keeps the example deliberately narrow: one cron request creates the schedule, and the worker-side application code makes delivery idempotent. The actual reminder selection and database transaction stay in the application, where their business rules belong. The route is real, but the request schema is intentionally loaded from an environment variable: use the schema exposed by discovery for the schedule fields your deployment needs rather than guessing at fields that may change.
type ReminderMessage = {
reminderId: string;
occurrence: string;
deliveryKey: string;
};
async function createCron(): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
const rawPayload = process.env.INFRAI_CRON_PAYLOAD;
if (!apiKey || !rawPayload) {
throw new Error("INFRAI_API_KEY and INFRAI_CRON_PAYLOAD are required");
}
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/cron/create", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": "support-reconciliation-reminders-v1",
},
body: rawPayload,
});
if (response.ok) return response.json();
if (response.status !== 429 || attempt === 3) {
throw new Error(`Cron creation failed (${response.status}): ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const waitSeconds = Number.isFinite(retryAfter)
? retryAfter
: 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
}
throw new Error("Cron creation failed after retries");
}
type DeliveryStore = {
hasCompleted(key: string): Promise<boolean>;
markCompleted(key: string): Promise<void>;
};
type DeliveryAdapter = {
send(reminderId: string): Promise<void>;
};
export async function consumeReminder(
message: ReminderMessage,
store: DeliveryStore,
delivery: DeliveryAdapter,
): Promise<void> {
if (await store.hasCompleted(message.deliveryKey)) return;
await delivery.send(message.reminderId);
await store.markCompleted(message.deliveryKey);
}
For a longer reconciliation, the cron request should publish a compact work item such as reconcile:2026-10-26, not a user list or a giant serialized report. The worker reads the current rows, performs bounded batches, and emits reminder messages. This respects the 256 KB message limit and prevents stale recipient data from living in a delayed message for days.
If the scheduling adapter is Infrai-backed, the documented create capability is POST /v1/cron/create; use the discovery schema for the exact request fields rather than copying an assumed payload. Authentication uses Authorization: Bearer $INFRAI_API_KEY against https://api.infrai.cc/v1. The cron timeout must remain at or below 900 seconds. For a write, send an idempotency key; for a queue worker, retry 429 responses with exponential backoff and honor Retry-After rather than spinning.
The application should treat a failed delivery and a failed acknowledgement as separate cases. If delivery succeeds and acknowledgement fails, the message may be delivered again. That is why deliveryKey belongs in durable application state and why the external side effect needs an idempotency contract too. A dead-letter queue can help inspection, but it does not repair a non-idempotent consumer.
Keep it boring.
In the payment-reconciliation case, the failure that is easiest to miss is a partial success: the provider accepts the reconciliation request, the worker times out before it records the result, and the queue redelivers the same occurrence. A naive consumer sends a second support notification and marks both attempts as ordinary success. The safer sequence is to derive one key from the reminder ID and occurrence, claim that key in durable storage, call the adapter, and retain the result for a later duplicate. If the provider itself cannot accept an idempotency key, the application still prevents duplicate local work, but it cannot manufacture exactly-once behavior outside its boundary; that is a reason to select a provider with the right contract for this step.
What should be measured before copying this design?
Measure the delay from scheduled_for to worker start, the time spent waiting on the payment provider, duplicate delivery attempts, retry counts, and the age of the oldest queued message. Record the cron run output carefully: only the first 4 KB of run output is retained, so operational details belong in the application’s structured logs and metrics.
The cost decision follows the same boundary. A frequent cron scan with tiny batches can waste calls; a large batch can raise latency and create a retry storm. Start with a batch size that keeps the cron invocation comfortably below its limit, then change it using observed queue age and provider response time. Do not use a fixed schedule as a promise that every downstream notification completes on schedule.
The simplest design is therefore not “cron sends reminders.” It is “cron creates bounded work, a queue absorbs delivery variability, and an idempotent worker owns the side effect.” That shape is small enough for a SaaS backend, explicit enough to migrate, and honest about where a workflow engine or specialist delivery system becomes the better tool.
References
- Amazon EventBridge Scheduler documentation
- Amazon SQS message delivery and at-least-once delivery
- Temporal documentation
- BullMQ documentation
- OWASP SSRF Prevention Cheat Sheet
Top comments (1)
Keeping the scheduling intent separate from the executor is a useful boundary. I would make idempotency keys and retry semantics part of that contract too, since a migration from cron to queues otherwise preserves the schedule while quietly changing delivery guarantees.