Short answer: keep each player's IANA timezone and recurrence rule in the database, calculate the next local occurrence in application code, and let a periodic Node.js dispatcher enqueue an idempotent shipment-update job for each due occurrence. Cron should wake the dispatcher; it should not be the source of truth for daily or weekly local time.
That distinction matters in a game with a shipment update fan-out. A player in New York and a player in Berlin can both choose 09:00, but their UTC instants differ, and the offset changes on different calendar dates. The durable record must describe the promise made to the player; the scheduler merely finds promises that are due.
Decision record: preserve the local obligation
The reminder record needs a stable identity, an IANA timezone such as America/New_York or Europe/Berlin, a recurrence kind, a local hour and minute, and the next UTC instant. For a weekly rule, store the local weekday too. Store a version of the rule, because an edited reminder must be distinguishable from an occurrence already dispatched.
For a shipment update, I model one logical occurrence as (reminder_id, local_date, rule_version). The dispatcher claims a due reminder in a short database transaction, creates an outbox row with that identity, advances the next occurrence, and commits. A separate relay publishes the outbox row. If the relay is interrupted after publishing, it can publish the same identity again; the worker must make the delivery ledger conditional on that identity rather than assuming the queue delivered exactly once.
Exactly once is an application invariant.
The delivery worker reads the occurrence, checks or creates its ledger record, fans the update out to the subscribers, and records the result before acknowledging the queue message. A retry after a lost acknowledgment then encounters the same occurrence key. The outbound subscriber operation also needs an idempotency key when the subscriber protocol supports one. Without that boundary, a worker can send a duplicate update after completing the external side effect but before recording success.
The outbox is not busywork. It closes the otherwise ambiguous gap between advancing a database row and publishing a queue message. A unique constraint on the outbox occurrence key, a conditional ledger insert, and a monotonic next_run_at update give reconciliation something concrete to inspect when two dispatcher processes race.
| Boundary | Durable fact | Retry rule |
|---|---|---|
| Recurrence | IANA timezone, local rule, rule version, next UTC instant | Recalculate from the stored rule and preserve the logical occurrence key |
| Dispatch | Claimed reminder and outbox occurrence ID | A second claim cannot create a second outbox identity |
| Delivery | Subscriber result and ledger state | Recheck the occurrence key before an external side effect |
| Recovery | Due rows, unpublished outbox rows, and terminal outcomes | Reconcile from storage; do not infer success from a queue acknowledgment |
How should Node.js cron and queue workers handle daily and weekly local time across US and EU DST?
Use a timezone-aware date library in the Node.js service and calculate the next wall-clock occurrence from a calendar date, not from “24 hours later.” Persist the resulting instant as UTC. The calculation must also return the local date used for the occurrence key, since the UTC timestamp alone does not explain which civil-time obligation was fulfilled.
There are two awkward DST cases, and the product needs a named policy for each. A spring-forward transition can remove a requested local time, such as 02:30. One defensible policy is to skip that local date and record the next valid daily or weekly occurrence. A fall-back transition can produce the same clock reading twice. Selecting the first matching instant and retaining one local-date key prevents two updates for one daily obligation. Shifting a missing time forward is also possible, but it changes the promise to the player and belongs in the rule specification and audit record.
I'm not sure which policy is least surprising for your game; the product requirement should decide whether “same wall time” or “once per local date” wins. Test both transitions in every supported US and EU zone, plus a zone without seasonal changes. Test the exact boundary, the minute before it, and a retry after the dispatcher has already written the outbox row.
Here is the failure sequence I would make explicit in a test fixture. A player in America/New_York selects a daily 02:30 reminder, and the local date reaches the spring transition. The calendar rule still exists, but that wall-clock minute does not map to a real instant. The calculator records the chosen skip-or-shift policy and produces the next valid occurrence; it does not manufacture a UTC timestamp by subtracting a stale offset. Months later, during the fall transition, 01:30 maps to two instants. The calculator selects the policy's occurrence, writes its local date and rule version, and the unique ledger key remains unchanged if the worker loses its acknowledgment. Now add a second dispatcher: it sees the same due row, attempts the same outbox identity, and loses the database uniqueness race. Finally, let the relay publish twice. The worker may observe two queue deliveries, but only one conditional ledger transition may authorize the shipment update. This test is more valuable than checking whether a cron expression parses, because it exercises the actual boundaries where civil time, concurrency, and external side effects meet.
The periodic scan controls lateness, not recurrence meaning. If the dispatcher runs every minute, a due reminder may wait for the next scan; that is a delivery-window concern. If the process is paused for an hour, the due query should still find rows with next_run_at <= now. Decide whether missed daily occurrences are sent once on recovery or skipped according to the product rule, and record that decision rather than silently converting a missed event into a new recurrence.
Critical path: one due shipment update
The following Go example shows the decision boundary without tying it to a queue product. The production Node.js implementation should use a maintained timezone library; the important shape is the same: find a real instant whose localized fields match the rule, then persist the instant and the local occurrence date together. The scan also refuses to reuse lastLocalDate, which makes the logical occurrence idempotent during a repeated fall-back hour.
package main
import (
"fmt"
"time"
)
type Rule struct {
Timezone string
Hour int
Minute int
Weekday *time.Weekday // nil means daily.
}
func nextOccurrence(after time.Time, lastLocalDate string, rule Rule) (time.Time, string, error) {
loc, err := time.LoadLocation(rule.Timezone)
if err != nil {
return time.Time{}, "", fmt.Errorf("load timezone: %w", err)
}
candidate := after.UTC().Truncate(time.Minute).Add(time.Minute)
limit := candidate.Add(15 * 24 * time.Hour)
for !candidate.After(limit) {
local := candidate.In(loc)
localDate := local.Format("2006-01-02")
weekdayMatches := rule.Weekday == nil || local.Weekday() == *rule.Weekday
wallTimeMatches := local.Hour() == rule.Hour && local.Minute() == rule.Minute
if weekdayMatches && wallTimeMatches && localDate != lastLocalDate {
return candidate, localDate, nil
}
candidate = candidate.Add(time.Minute)
}
return time.Time{}, "", fmt.Errorf("no occurrence found within 15 days")
}
func main() {
monday := time.Monday
rule := Rule{Timezone: "Europe/Berlin", Hour: 9, Minute: 0, Weekday: &monday}
after := time.Date(2026, time.March, 27, 12, 0, 0, 0, time.UTC)
next, localDate, err := nextOccurrence(after, "", rule)
if err != nil {
panic(err)
}
fmt.Printf("next_run_at=%s occurrence_date=%s\n", next.Format(time.RFC3339), localDate)
}
The bounded minute scan is intentionally easy to audit, not a claim that every high-volume service should scan every minute. A Node.js service with many reminders should calculate candidates with a tested library and keep the database constraint; it should not duplicate timezone rules in cron expressions. For a weekly rule, the search window must cover the next matching weekday. For a daily rule, the next valid local date must advance even when a clock time is absent.
Failure boundaries, retries, and operational evidence
The worker's visibility or lease interval must exceed the time it normally needs to fan out an update, while the worker should extend that lease for an unusually large subscriber set. A message becoming visible again is not proof that the first worker did nothing. It is a signal to re-run the idempotency check. Queue metadata is useful operational evidence, but the reminder database and delivery ledger are the audit trail.
Keep queue payloads small: an occurrence ID, reminder ID, rule version, and shipment update ID are enough. The durable shipment data, subscriber selection, and audit fields belong in storage that can be queried and reconciled. A consumer should distinguish a temporary delivery failure from a permanent invalid subscriber target, apply bounded retry policy, and write the terminal reason. Retrying a malformed destination forever is not resilience; it is an unbounded duplicate opportunity.
Observability should expose the fields needed to answer one question: what happened to this player's logical occurrence? Log the occurrence ID, local date, timezone, computed UTC instant, dispatcher claim, outbox state, attempt number, subscriber result, and final ledger state. Alert on due rows that remain unclaimed, outbox rows that remain unpublished, and ledger rows whose attempts exceed policy. I use error code REMINDER_DUPLICATE_OCCURRENCE as a domain outcome in tests, not as a reason to retry.
Compliance limits belong here too. Timezone and shipment metadata can identify a player, and retention, deletion, regional storage, and access rules vary by jurisdiction. A delivery ledger should retain only what the product's audit and legal requirements justify. Your mileage may vary across US and EU deployments; have the retention period and deletion behavior reviewed before treating logs as evidence.
The rejected design and its valid use case
I would reject one cron schedule per player that invokes delivery directly. It scatters recurrence state across scheduler objects, makes a timezone rule hard to reconcile with the player record, and couples delivery duration to the scheduler. It also makes a DST policy look like a parser detail when it is a product decision. Cron syntax is a good fit for a small number of fixed infrastructure tasks; it is not a durable per-user ledger.
The catch is that this architecture is not suitable when the application needs native workflow branching, long-running joins, or a scheduler that owns the full delivery lifecycle. Use a workflow engine for that class of problem. It is also not suitable when the product deliberately promises sub-minute delivery precision; then the dispatcher cadence, clock source, and queue latency need a different service-level design. For ordinary daily and weekly user reminders, the database-plus-outbox boundary keeps the important facts inspectable without making the queue responsible for calendar semantics.
The decision rule is narrow: preserve the player's local-time promise, create one durable occurrence identity, publish through an outbox, and make every retry harmless. Everything else is an implementation choice that should be judged against those invariants.
Top comments (0)