Short answer: most missed reminders are not caused by cron failing to start. They happen when a scheduled run loses its time semantics, retries a partially completed send, or has no durable record of what it already delivered. For a weekly e-commerce digest, I would treat the cron job as a planner, persist one delivery intent per customer and period, and let workers claim those intents with an idempotency key.

The evaluation constraint matters: the system must tolerate a late run, a duplicated run, and a worker dying after the provider accepts a message. A design that only works when every process exits cleanly is not a scheduling design; it is a lucky demo. The useful comparison is simple cron plus direct sends versus cron plus a durable work queue and a delivery ledger. The second design adds state and operational work, but it makes retry behavior explicit.

What are common causes of delayed or missed user reminders?

A cron expression describes when a process should be attempted. It does not guarantee that the process ran on time, that the host was awake, or that one invocation exists. A deployment can replace the process during its window. Two replicas can both evaluate the same weekly boundary. A daylight-saving transition can make a local timestamp ambiguous. A backlog can then make a reminder arrive hours after its intended time even though the scheduler itself behaved normally.

It is a small distinction, but it changes the design.

For the weekly digest, the contract has three different clocks: the period being summarized, the time the planner creates work, and the time a downstream service accepts a message. They can disagree without any one component being broken. A digest created late may still be valid for last week; a message accepted quickly may still be a duplicate; and a worker retrying an old intent may be correct even though the current customer record has changed. I would write those meanings into the data model before choosing a cron expression. Otherwise a later operator has to infer intent from timestamps, and timestamps are evidence, not a decision.

There is a second boundary after scheduling. The application may select active customers, construct messages, and call a delivery service in one loop. If the loop dies after 430 of 500 sends, rerunning it needs to know whether customer 430 was accepted. If it knows nothing, the retry must choose between duplicates and omissions. Neither is a harmless edge case for a digest that contains order status or expiring offers.

I started with the tempting model: run one Node.js function every Monday, query active customers, and send each result immediately. It looked appropriately small. The problem was not syntax or cron accuracy. The model had no durable answer to “which customer-period pair is complete?” Once that question appears, a boolean success value in the process memory is too weak.

When should a reminder attempt stop retrying?

A transient database timeout and a permanent invalid recipient should not receive the same retry schedule. Classify failures at the boundary, cap attempts, and send unrecoverable rows to a reviewable dead-letter state. Exponential backoff with jitter prevents every worker from retrying at once after a shared outage, but it also increases delivery delay. For a weekly digest, that trade-off is usually acceptable until the digest becomes time-sensitive; the policy should be tied to the reminder’s useful lifetime.

The retry record needs enough detail to explain a customer report later: attempt number, reason class, last error code, lease owner, and timestamps. Do not log message bodies or tokens merely because they are available. A compact event history is more useful than a noisy application log, and it keeps sensitive customer content out of routine debugging.

The delivery ledger is the real API

The chosen unit is a delivery intent: {customerId, digestWeek}. digestWeek is a canonical UTC period identifier, not a formatted local date. The database enforces uniqueness on that pair. The scheduler inserts intents and exits; workers perform the slow, failure-prone delivery.

This is the small state transition I want to test before adopting the pattern:

type DeliveryState = "pending" | "leased" | "sent" | "failed"; interface DigestIntent { customerId: string; digestWeek: string; state: DeliveryState; attempts: number; nextAttemptAt: string; idempotencyKey: string; } function keyFor(customerId: string, digestWeek: string): string { return `weekly-digest:${customerId}:${digestWeek}`; } 

The insert is intentionally repeatable. A second cron invocation for the same period should find the existing row and produce no new work. The worker claims a pending row with a lease and a deadline. If the process vanishes, another worker can reclaim it after the deadline. A lease is not completion; confusing those states is a common source of silent loss.

Keep the key stable.

Here is the practical trade-off I would put in the design review:

Approach Integration Useful when Main limitation
Direct cron loop Process-local code Small, low-consequence reminders A crash leaves delivery state ambiguous
Cron plus durable intents Database and worker interface One customer-period must be traceable Requires leases, cleanup, and metrics
Scheduler plus external queue Queue protocol and consumer Work is bursty or spans replicas Adds another delivery boundary to operate

The hard case is the acknowledgement window. A worker can submit a message, receive acceptance, and die before recording sent. A later attempt may repeat the message. The application can reduce that risk by sending the same idempotency key on every attempt and by retaining the key long enough to cover the retry window. If the downstream boundary cannot provide deduplication, the system should expose that limitation in its delivery contract instead of pretending an exactly-once effect exists.

async function deliver(intent: DigestIntent): Promise<void> { const payload = await buildDigest(intent.customerId, intent.digestWeek); await sendMessage({ recipient: intent.customerId, body: payload, idempotencyKey: intent.idempotencyKey, }); await markSent(intent.customerId, intent.digestWeek); } 

That code is not the whole system. It makes the dangerous ordering visible: sendMessage can succeed while markSent does not. The test must inject a process stop at that boundary and verify the resulting behavior. A passing test means a retry is harmless or clearly reported as uncertain; it does not mean the database row alone solved delivery.

Which cron assumptions should the test challenge?

The first test is late execution. Stop the scheduler for 90 minutes, restore it, and verify that the missed period creates the intended intents rather than silently advancing the period. The second is overlap: run two scheduler instances concurrently and assert that the uniqueness constraint leaves one intent per customer and week. The third is clock interpretation: generate the same period from UTC and from the business timezone around a daylight-saving transition.

I would also test a long customer query. A weekly job that takes 25 minutes can overlap with its next invocation if the schedule runs every 15 minutes. A process-local lock protects only one process. A database lease or an external coordination primitive is needed when multiple replicas are possible, and its timeout must exceed the longest expected critical section with room for renewal.

The schedule should be boring. A useful planner records plannedFor, createdAt, and the period key, then emits metrics for lag. “The cron command ran” is not a customer-facing success metric. The metrics that matter are planner lag, queue age, lease expiry count, attempt count, duplicate-key conflicts, provider acceptance latency, and the age of the oldest pending intent.

The clock is part of the data.

I measure cost in work performed, not just queue size. Rebuilding a large digest for every retry consumes database and model tokens before any message is sent. Persisting a stable payload or a content hash can prevent needless recomputation, but it creates retention and invalidation questions. The right choice depends on whether customer data can change between attempts and whether the digest is allowed to reflect the original snapshot.

What should be measured before copying this design?

Before shipping, I would run a bounded failure matrix with a small synthetic customer set: scheduler late, scheduler duplicated, worker killed before send, worker killed after acceptance, downstream timeout, malformed recipient, and lease expiry. For each case, record how many messages were accepted, how many customer-period rows became sent, and how many remained reviewable. The desired result is not a magical zero in every column. It is a known result with an operator action.

The production dashboard should show the 95th and 99th percentile delivery lag, not only the average. It should separate planner lag from queue lag and provider latency; combining them hides the boundary that is failing. Alert on old pending work and repeated lease expiry, then sample the idempotency-key collision rate. A rising collision rate may mean duplicate scheduling, a slow consumer, or a key-generation bug.

The simplest useful architecture is therefore a cron-triggered planner, a durable intent table, leased workers, an idempotent delivery boundary, and metrics that follow the intent across every handoff. It costs more operational attention than a direct loop. That is the honest trade-off. For customer reminders, duplicate and missing messages are user-visible, so explicit state is worth more than a short first implementation.

The decision rule is narrow: if a reminder can be safely regenerated and duplicates are harmless, a direct scheduled job may be enough. If each customer must receive one digest for one period, make that identity durable before adding more retry machinery. The scheduler chooses when to create work; it should not be responsible for proving that work was delivered.

References