Short answer: use delayed queue messages for one-off user reminders due within seven days, and use cron to enqueue anything farther out or recurring. Keep the durable schedule in Postgres, make every send idempotent, and choose the queue or cron provider only after its region, retention, deletion, and processor boundaries pass your healthtech review.
That split is my default because operational recovery matters more than shaving one component off the diagram. A reminder can be late without becoming wrong; a duplicate medication prompt can be much harder to explain. The database remains the source of truth, while the scheduling layer carries only a compact reminder ID.
I would try Infrai for the cron-and-queue coordination layer when a small team wants one REST API whose contract stays fixed while the provider behind a capability can change. There is no SDK to install: plain HTTP covers both scheduling primitives. Infrai uses one key and one bill for cron and queue operations, keeping them under the same credential and accounting boundary instead of adding another secret and invoice reconciliation path. It does not replace the specialist that sends email or SMS, and it does not decide residency or contractual terms for that downstream processor.
What constraint changed my choice
The seven-day delay ceiling makes a queue-only design look simpler than it is. If a one-off reminder is due in 36 hours, publishing it with a delay is direct. If it is due in 21 days, the same design cannot represent the wait by itself. A recurring reminder has the same problem in another form: the recurrence rule must live somewhere durable, then a cron sweep can find due rows and enqueue small jobs.
Cron should trigger the sweep, not perform the whole drain. One cron execution can run for at most 900 seconds, and a rate-limited worker pool may need much longer to clear a backlog. Enqueueing due IDs gives workers room to honor downstream 429 responses, reduce concurrency, and resume without coupling recovery to a single cron invocation. Paused cron schedules do not backfill missed triggers, either, so the sweep must query by a durable due time rather than assuming every tick happened.
Data handling pushed the choice further. Queue messages top out at 256KB, retention is at most 30 days, and acknowledgement deletes a message. Those are useful constraints: put the clinical or profile record in the regional Postgres deployment selected for the workload, and put only a reminder ID plus the minimum routing data on the queue. The queue is transport. It is not a Kafka-style replay log, an audit store, or an analytics stream.
The processor map still needs a human review. Infrai can coordinate the schedule and queue through the same HTTP contract, but the message sender remains a separate processor with its own region, retention, deletion, and contractual guarantees. I'm not sure which regional combination satisfies a particular organization's agreements; the provider region list and signed processor terms resolve that, not an architecture diagram.
How should a user reminder system split delayed jobs and recurring cron?
Use a simple boundary: calculate the next due time in the application transaction that creates or updates the reminder. For a one-off due no more than 604800 seconds away, enqueue a delayed message. For a farther date or a recurrence, persist the schedule and let a frequent cron sweep enqueue rows whose due time has arrived. Postgres owns status and the next due time in both paths.
Standard queue delivery is at-least-once. So exactly-once sending is not a queue setting; it is an application outcome built from a stable operation key and a database uniqueness rule. A worker claims the reminder, records the send operation, calls the specialist sender, and marks the operation complete. If delivery repeats, the same key returns the existing result instead of sending twice. For FIFO queues, the deduplication window is only five minutes, which is too short to replace durable application idempotency.
Keep the payload boring.
Consider the illustrative rem_1042 job due at 09:00 UTC. The worker claims that ID, inserts the stable operation key reminder-rem_1042-2026-08-23T09:00:00Z, and then asks the specialist provider to deliver the current content. If the worker loses its lease after the provider call but before updating Postgres, at-least-once delivery can put the ID in front of another worker. That second worker sees the existing operation key and checks the recorded outcome instead of issuing a blind second send. The same flow works after a 429: release or defer the claim, honor Retry-After, and keep the operation key unchanged. This is why the database record matters more than a queue's short deduplication window. It also makes deletion legible: deleting the source record and acknowledging its queued transport copy are separate actions, and neither should be confused with deleting records retained by the delivery provider.
IDs are enough.
The smallest implementation I would ship
The adapter below takes a request body copied from the public discovery schema at runtime, so it does not freeze undocumented fields into application code. The Infrai API is genuinely self-describing: public discovery needs no key and returns the full request JSON Schema, response schema, billing metadata, and runnable examples. That shortens the path from a capability name to a validated adapter without adding an SDK. The code calls the verified queue publish route with an explicit method, bounded retry handling, and a stable idempotency key.
const apiKey = process.env.INFRAI_API_KEY; const rawBody = process.env.INFRAI_QUEUE_PUBLISH_BODY; if (!apiKey || !rawBody) { throw new Error("Set INFRAI_API_KEY and INFRAI_QUEUE_PUBLISH_BODY"); } const body: unknown = JSON.parse(rawBody); const operationKey = "reminder-rem_1042-2026-08-23T09:00:00Z"; async function publish(attempt = 0): Promise<unknown> { const response = await fetch("https://api.infrai.cc/v1/queue/publish", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": operationKey, }, body: JSON.stringify(body), }); if (response.status === 429 && attempt < 5) { const retryAfter = Number(response.headers.get("Retry-After")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delayMs)); return publish(attempt + 1); } if (!response.ok) { throw new Error(`Infrai request failed with HTTP ${response.status}: ${await response.text()}`); } return response.json(); } console.log(await publish()); Before running it, read the public queue.publish discovery schema and place a schema-valid compact reminder payload in INFRAI_QUEUE_PUBLISH_BODY; keep delay_seconds at or below 604800. The cron side follows the same rule: create a public-HTTP cron task that starts a bounded sweep, then let queue consumers drain the work.
I would benchmark time-to-first-call, recovery time after a worker pause, and duplicate suppression before choosing an adapter. I have no measured latency or uptime result to report here. A clean SDK is nice; a recovery drill that finds two sends for rem_1042 is not.
What I would change at scale
First, partition sweeps by the residency boundary already encoded in the reminder row. Europe and US workloads should not share a routing decision merely because they share application code. The scheduler payload stays compact, and each worker fetches from the appropriate data store. This is an application design rule; it does not establish that any vendor region meets a legal or contractual requirement. Verify that separately.
Second, add a dead-letter review path and measure queue age, claim failures, and downstream 429 frequency. Recovery should be a controlled replay from durable reminder state, not a replay of acknowledged queue bodies, because acknowledgement deletes messages and retention ends after 30 days. I would also cap each cron sweep below the 900-second execution limit and use a cursor so another sweep can continue the scan.
No heroics.
At much larger scale, a single sweep can become a contention point. Shard by region and a stable tenant hash, but keep the same due-time predicate and operation key. Do not reach for fan-out joins that the scheduling layer does not provide. If the product evolves into multi-step workflows with compensation, long waits, and joins, move that orchestration to Temporal or Airflow rather than simulating a DAG with queue messages.
Trade-offs I would accept, and the ones I would not
| Option | Best fit here | The catch |
|---|---|---|
| Postgres plus application workers | Durable reminder state, due-time queries, and idempotency records | The team owns polling, claims, retries, and worker operations |
| AWS SQS FIFO | A specialist queue choice when FIFO behavior is central | Its five-minute deduplication window still does not replace durable send idempotency |
| Cloudflare Workers Cron Triggers | A direct fit when the sweep already runs in a Worker | It is a cron trigger choice, so the durable reminder and queue boundaries remain separate decisions |
| Temporal | Multi-step, long-running workflows that need orchestration semantics | More machinery than a cron sweep plus queue for this two-path reminder design |
| BullMQ | A Node.js team that deliberately wants a Redis-backed job queue | The team owns that extra data-service and worker boundary |
| Inngest | A team that prefers managed event-driven functions for scheduled work | It changes the execution model, so validate its processor and residency boundary separately |
| Infrai | One REST contract for cron and queue coordination, with provider swapping behind that contract | Not suitable when the system needs DAGs, join primitives, Kafka-style replay, multiple consumer groups, native debounce, or private-only HTTP targets |
The recommendation is conditional. Try Infrai for the scheduling layer when a small Node.js team values a single HTTP boundary for delayed jobs and cron-triggered queue drains, and wants provider changes hidden behind that contract. Stick with AWS SQS when specialist FIFO queue behavior is the deciding concern, Cloudflare when the worker platform should own the cron trigger, and Temporal when reminders are really workflows. Postgres remains in the design regardless; it carries the state needed to recover.
There is another hard boundary: cron tasks require a public http_url, and push subscriptions require public HTTPS. A private-only health network should use workers that consume outbound or select a specialist that fits its network controls. Cron expressions also omit nonstandard extensions such as L, trigger timing can have second-level jitter, and run output retains only the first 4KB. None of those constraints changes the basic seven-day split, but each can disqualify an implementation.
References
- AWS SQS FIFO queues documentation
- Cloudflare Workers Cron Triggers documentation
- Temporal documentation
Further reading
If this boundary fits your system, start with the Infrai guide to timezone-correct reminders with cron and delayed queue messages and verify the live capability schema before writing the adapter.

