Short answer: model each reminder occurrence as durable application data, use delayed work to process a specific due occurrence, and use cron only as a periodic trigger for bounded maintenance or materialization. The deciding constraint is recoverability: a reminder must still have an explainable state after a process restart, a retry, or a user cancellation.
This can start with a Postgres-backed ledger and workers when Postgres is already the application's durable store. It does not require treating a timer library as a scheduler of record. In a Node.js application, the web process can create or update reminder rules while a separate worker claims due rows; the same data model also works when a Python worker owns the scheduling loop. Keep the contract in the database, not in a long-lived process.
The cheap-looking version is an in-memory timer per reminder. It works in a notebook. Deploys, restarts, and horizontal scaling make its state private and disposable. A periodic cron task is safer than that, but cron alone does not capture the user-owned history needed for a snooze, cancellation, retry, or support investigation.
How should a user reminder system use delayed jobs, cron, and Postgres?
Separate the rule from the occurrence. A rule says that a user wants a weekday reminder at a local wall-clock time. An occurrence says that one concrete delivery is due at one UTC instant. The distinction sounds fussy until a user changes a time zone after the next reminder was created, or presses cancel while a worker is claiming it.
Store the rule, its IANA time-zone identifier, and its recurrence definition. Materialize upcoming occurrences into a table with a stable identifier, a due_at value, and a state such as pending, claimed, ready, sent, or cancelled. A worker queries a small batch of due pending rows, claims them transactionally, and creates a delivery intent. The sender does network I/O after the claim. This keeps a slow notification call from holding database locks.
Delayed jobs fit the concrete occurrence: one piece of work becomes eligible at a known instant. Cron fits a coarser responsibility: wake a worker every minute, refill a rolling occurrence horizon, release expired leases, or perform retention cleanup. Cloudflare's Cron Triggers documentation describes cron expressions in UTC and a scheduled handler. UTC is useful for a wake-up clock; it is not a complete representation of a person's “09:00 Europe/Berlin” intent across local clock changes.
One source of truth helps. A queue can carry work, but it should not be the only place that knows a user asked for, cancelled, or received a reminder.
A small transaction is the part worth evaluating
The scheduling path needs an idempotency boundary. A worker can lose its connection after committing a change but before observing the acknowledgement. Retrying based only on a successful client response then becomes unreliable. A unique key on the occurrence's delivery intent gives the retry a stable outcome: either it creates the one intent or it finds the one that already exists.
from uuid import UUID
import psycopg
def materialize_delivery(conn: psycopg.Connection, occurrence_id: UUID) -> bool:
with conn.transaction():
row = conn.execute(
"""
SELECT id, channel, payload
FROM reminder_occurrences
WHERE id = %s AND status = 'pending' AND due_at <= now()
FOR UPDATE
""",
(occurrence_id,),
).fetchone()
if row is None:
return False
conn.execute(
"""
INSERT INTO reminder_outbox (occurrence_id, channel, payload)
VALUES (%s, %s, %s)
ON CONFLICT (occurrence_id) DO NOTHING
""",
row,
)
conn.execute(
"""
UPDATE reminder_occurrences
SET status = 'ready', claimed_at = now()
WHERE id = %s
""",
(occurrence_id,),
)
return True
The focused example deliberately stops before a sender implementation. Delivery providers have their own acknowledgement semantics, so the final send needs its own idempotency key and an event trail. The useful tests are not “did the endpoint return 200?” Tests should freeze time, rerun this transaction, interrupt a worker between durable transitions, and race a cancellation against a claim. Assert one durable intent per occurrence, no future send for a completed cancellation, and a recoverable lease when a claimant goes away. A practical harness can begin with a fixed now, a small set of rows, and a fake sender that records attempts by occurrence identifier. Run the same occurrence through the harness twice; then mark it cancelled before the second claim; then give the first claimant a lease that expires before the sender records its acknowledgement. Each case should leave the ledger in a state the next worker can understand without consulting process memory. Add a batch where several rows share a due time, because this reveals whether the query claims work in bounded chunks or makes a single worker hold too much database work at once. Add a recurrence near a local clock transition, because a valid UTC timestamp alone does not prove that the original local-time promise was applied as intended. The point is not to manufacture a giant simulator. It is to make the important choices inspectable: which transition owns a delivery intent, what cancellation is allowed to prevent, how a lease returns work to the system, and when the next recurring occurrence is created. Those tests also make architecture changes comparable. A team can swap a worker loop for a queue consumer and keep the same assertions around the durable ledger.
Measure the state, not the timer.
This is where an eval harness earns its keep. It turns clock behavior and retry ambiguity into repeatable input cases rather than an argument during an incident review. Record due-to-claim latency, backlog age, claim duration, duplicate-intent count, retry count, and sender acknowledgement latency. Averages hide the exact burst that makes reminders late, so look at percentiles and the oldest due item too.
Where does the cheapest architecture stop being the right one?
“Cheapest” includes the operational surface, not merely a monthly service line item. A Postgres table plus worker is usually a small system when the application already operates Postgres and reminders have a moderate delivery window. It adds indexes, leases, cleanup, and a worker loop, but avoids making a second datastore authoritative for user intent.
| Shape | Good fit | Cost that must be measured |
|---|---|---|
| Postgres ledger and workers | Existing database, moderate volume, clear delivery window | Database scans, connections, lease recovery |
| Queue plus application ledger | Bursty delivery work or a need for worker isolation | Reconciliation, cancellation races, two operational systems |
| Cron plus durable rows | Coarse timing and modest volume | Tick interval, backlog growth, scan efficiency |
| Workflow engine | Long, multi-step business processes with compensations | Programming model and operating overhead |
The catch is shared capacity. A database-backed worker is not suitable when due-time scans or notification bursts materially interfere with primary transactional traffic, or when independent failure isolation is a hard requirement. In that case, put delivery work behind a separate queue while retaining the application ledger for user intent and history. Conversely, stick with cron plus durable rows when several minutes of lateness are acceptable and a new durable service would add more operating burden than it removes.
There is no universal queue-depth threshold. The source material cannot decide a team's acceptable lateness, database headroom, or on-call capacity; a load test with the expected burst shape and an explicit service-level objective can.
Time zones, regions, and recurrence are separate decisions
Store each executable occurrence as a UTC instant, along with the original recurrence rule and IANA zone. A fixed numeric offset will eventually misrepresent a local-time rule when clock rules change. The product also needs a stated policy for ambiguous or nonexistent local times. It may skip an occurrence, choose the earlier matching instant, or move forward to the next valid local time. The policy is a product decision, but leaving it implicit makes behavior impossible to explain.
For users in Europe and the US, start with one authoritative scheduling owner when residency and latency requirements allow it. Reminder calculation is rarely improved by racing two regions to claim the same row. If regional ownership is necessary, assign a durable home region to the reminder and move ownership through one observable state transition. The sending edge can be geographically closer than the scheduler; those are different latency problems.
Test the calendar cases that demos avoid: a rule across a clock change, reschedule just before due_at, cancellation during a claim, an expired lease, a repeated worker attempt, and a backlog that exceeds one polling interval. Don't test only noon.
What should a team measure before shipping recurring reminders?
Define the objective in user terms: the proportion of reminders delivered within an allowed window after their intended instant. Pair it with backlog age, rows scanned per claimed row, database saturation, retry exhaustion, cancellation-versus-claim outcomes, and delivery intent duplicates. Put sensitive payloads outside metric labels and logs.
Keep a compact event trail for every occurrence: created, rescheduled, claimed, delivery intent created, delivery acknowledgement recorded, cancelled, and recurrence advanced. It supports support work and lets an offline evaluation replay legal transitions. Give that history a retention policy; an audit table that grows forever changes the cost model over time.
Choose the smallest durable design that meets the measured delivery window, then make its state transitions observable. That is a better foundation for a reminder system than picking a timer mechanism by its demo alone.
Top comments (0)