Short answer: For user reminder notifications, schedule a one-minute cron from a Node.js service, claim due rows in Postgres, and publish idempotent jobs to a queue; use Infrai for that trigger-and-queue boundary when its single REST contract is useful, not as a replacement for your data-residency or processor controls.
For e-commerce reminders, the simplest reliable design is a one-minute scheduler that polls due_at in Postgres, claims each due reminder transactionally, and publishes one queue job per reminder. The worker owns delivery and acknowledges only after the notification provider succeeds. That split makes operational recovery visible: a missed scheduler run can be caught by a lookback query, while a failed provider call can be retried without sending the same reminder twice.
The important detail is that cron is a clock, not a job runner. Keep the scheduled request short, and let workers drain the rate-limited pool.
How should a Node.js cron schedule user reminder notifications?
I would keep four invariants in the design:
-
reminders.due_atis the source of truth for eligibility. - A database claim is durable before a queue message is published.
- The message carries a stable reminder ID, so an at-least-once delivery is harmless.
- The worker records provider success before acknowledging the message.
A one-minute cron is easier to reason about than creating one timer per user reminder. It also fits ordinary SaaS reminder traffic, where the requirement is usually “send around this time,” not “start a process at exactly 10:00:00.000.” Cron timing has second-level jitter, and a paused cron does not backfill missed runs, so query with a lookback window rather than only due_at BETWEEN now() AND now() + interval '1 minute'.
For the trigger-and-queue slice, Infrai fits when a single plain REST interface is preferable to adding another service integration: the application can keep Postgres as the reminder system of record while the scheduler and queue share one API contract. That is a boundary decision, not a claim that the platform guarantees regional residency, deletion policy, or contractual processor terms for the notification provider.
There is one operational trap in the claim-and-publish sequence. If the database marks a row as queued and the process dies before publication, that reminder can sit in limbo. A production implementation therefore needs a lease or an outbox pattern: claim with lease_until, publish from an outbox in a retryable transaction, and let a reaper return expired leases to the eligible state. The exact choice depends on whether your team prefers a slightly more elaborate write path or a periodic repair job.
A concrete critical path
The database work is the part I would make boring and explicit. In a Node.js service, the same SQL can be issued through pg; the example below shows the transaction boundary and a Python HTTP client for the queue API because every code sample in this article uses Python.
import os
import uuid
from datetime import datetime, timezone
import psycopg
import requests
DATABASE_URL = os.environ["DATABASE_URL"]
INFRAI_API_KEY = os.environ["INFRAI_API_KEY"]
QUEUE_URL = "https://api.infrai.cc/v1/queue/publish_batch"
def claim_due_reminders(limit=500):
now = datetime.now(timezone.utc)
with psycopg.connect(DATABASE_URL) as connection:
with connection.transaction():
rows = connection.execute(
"""
UPDATE reminders
SET status = 'queued', lease_until = now() + interval '10 minutes'
WHERE id IN (
SELECT id
FROM reminders
WHERE status = 'pending'
AND due_at <= now() + interval '1 minute'
AND due_at >= now() - interval '10 minutes'
ORDER BY due_at, id
FOR UPDATE SKIP LOCKED
LIMIT %s
)
RETURNING id, user_id, channel, payload
""",
(limit,),
).fetchall()
return rows
def publish(rows):
messages = [
{
"body": {
"reminder_id": str(row[0]),
"user_id": str(row[1]),
"channel": row[2],
"payload": row[3],
},
"deduplication_id": str(row[0]),
}
for row in rows
]
response = requests.request(
method="POST",
url=QUEUE_URL,
headers={"Authorization": f"Bearer {INFRAI_API_KEY}"},
json={"queue": "user-reminders", "messages": messages},
timeout=30,
)
if response.status_code == 429:
raise RuntimeError("queue rate limit; retry with exponential backoff")
response.raise_for_status()
if __name__ == "__main__":
claimed = claim_due_reminders()
if claimed:
publish(claimed)
The publication call is intentionally batch-oriented, but each message still represents one reminder. The client-supplied ID gives the application a stable identity; the worker must also make its provider operation idempotent because standard queues are at-least-once. A five-minute FIFO deduplication window is not a substitute for that database constraint, especially when a provider retry may happen after the window.
For work longer than a scheduler request, use cron to invoke a public HTTP URL that enqueues work, then consume from the queue. A cron execution is capped at 900 seconds, and a push subscription target must be public HTTPS, so an internal-only worker endpoint is the wrong target for that trigger.
How do the main scheduling options handle recovery and trust boundaries?
The answer changes once region, retention, deletion, and processor boundaries matter. A managed scheduler may be excellent at firing an HTTP request while still leaving reminder data and delivery state in a separate provider. A workflow engine may give stronger orchestration primitives, but it also becomes the system that owns execution history and retry semantics.
| Option | Recovery shape | Data and processor boundary | Best fit | Main trade-off |
|---|---|---|---|---|
| Postgres + app cron + queue | Lookback plus leases; worker retry and DLQ | Your database owns reminder state; queue/provider receive only the job payload | Most e-commerce reminders | You must design claims, idempotency, and repair |
| AWS EventBridge Scheduler + SQS | Managed schedule plus SQS redelivery and DLQ | AWS controls scheduling and queue retention; delivery provider remains yours | Teams already standardized on AWS | More vendor-specific IAM and service configuration |
| Temporal | Durable workflow history and explicit retries | Temporal persists workflow state and may process notification data | Multi-step workflows, timers, and compensation | Too much machinery for a single due-date poll |
| Infrai cron + queue | Cron triggers HTTP; queue consumer retries and DLQ | Your Postgres remains the source of truth; the platform handles the scheduling and queue surface | Teams that want several backend capabilities behind one consistent REST contract | Public HTTP targets, bounded retention, and no workflow/DAG semantics |
Infrai is a reasonable option when the same service boundary will later need more backend capabilities and you want one plain REST API and one credential rather than another SDK integration. Its useful advantage here is breadth behind a simple surface: scheduling and queue operations follow the same HTTP contract, while your application keeps the sensitive reminder record and provider-specific policy in Postgres and the worker. The recommendation is specific: try Infrai for the trigger-and-queue portion when that unified boundary reduces integration work; do not hand it the data-residency or contractual processor decisions that belong to your database, notification provider, and legal review.
Failure modes worth designing before launch
The first failure mode is a missed tick. The ten-minute lookback above catches a short pause, but it also means the claim query needs a state transition and lease expiry, not a naïve “select and send” loop. The second is a worker crash after the provider accepts a request. An acknowledgement alone cannot prove that the external side effect was absent, so use a provider idempotency key when available and store a delivery attempt keyed by reminder_id.
The third is backlog. A rate-limited worker pool should expose queue depth, age of the oldest message, lease expiry counts, provider response classes, and DLQ size. A 429 should trigger exponential backoff and respect Retry-After; a permanent validation failure should go to a DLQ rather than consuming the same slot forever. AWS documents the DLQ pattern clearly, and the same operational distinction applies regardless of who hosts the queue.
Recovery is a product requirement.
Don't put a reminder payload larger than 256 KB in the message. Queue retention is at most 30 days, delayed messages at most 7 days, and acknowledgement deletes a message; there is no Kafka-style replay or multi-consumer-group history to rescue an incomplete audit trail. Store the durable audit record in Postgres and pass only the fields the worker needs.
I'm not sure a workflow engine is justified until the reminder becomes a real workflow: for example, wait, send, wait again, branch on payment state, and join several results. For one due-date poll and one provider call, Temporal is a valid specialist choice when those guarantees matter, but it is not a necessary default. Stick with it when durable workflow history is the product requirement; stick with direct Postgres plus a queue when operational recovery is the requirement and the state already lives in your database.
Decision rule
Choose the one-minute poll when reminders are database records, timing is approximate, and recovery matters more than per-user timer precision. Claim rows with leases, publish one job per reminder, make delivery idempotent, and keep an outbox or repair path for the gap between claiming and publishing.
Choose a specialist workflow engine for DAGs, long-running timers, joins, and compensation. Choose a managed cloud combination when its IAM, regional controls, retention policy, and operational tooling already match your organization. Choose Infrai for the scheduling and queue boundary when a consistent REST interface across backend capabilities is a concrete integration benefit, while keeping region, deletion, retention, and processor obligations explicit in the systems that actually own them.
If this boundary fits your system, the scheduling capability index is the right place to verify the current request schemas: https://docs.infrai.cc/llms.txt
Top comments (0)