Short answer: store each user's local-time rule and IANA time-zone identifier, calculate one next occurrence at a time, enqueue a delivery with a stable occurrence key, and let an idempotent worker retry it without changing that key. For property-management reminders, that is the least complex design that survives daylight-saving changes and operational replay without turning one intended notice into several webhook deliveries.
The bill starts with retention, not cron. If there are 100,000 active reminders and the system keeps a scheduling snapshot every day for 30 days, it holds 3,000,000 reminder-days of history before logs, queue requests, or webhook attempts enter the calculation. Keeping only the current rule, the next occurrence, and a compact delivery ledger changes the dominant retained state from reminders x snapshots x days to roughly reminders + occurrences inside the recovery window. This isn't a price claim; it is the capacity term worth measuring first.
Do not discard the evidence needed to explain a delivery. Keep the immutable occurrence key, scheduled instant, time-zone identifier, rule version, attempt state, and receiver acknowledgement for a recovery window tied to the business's dispute process. Deliberately stop keeping full scheduler snapshots and repeated webhook bodies once their audit value expires. The catch is straightforward: after that expiry, an operator can prove that an occurrence existed and was acknowledged, but may no longer be able to reconstruct every byte of an old payload.
Retention cost follows the audit window
Treat cron as a wake-up signal, not as the source of truth. A periodic Node.js process can claim due rows, but the database record decides which local occurrence is next. GitHub Actions documents an instructive boundary for scheduled workflows: scheduling is based on UTC, scheduled runs can be delayed under high load, and some queued jobs may be dropped. That makes it a poor clock for per-user local-time promises, although it can still wake a reconciliation job where minute-level delay is acceptable.
The durable record needs a small, explicit contract:
-
schedule_rule: daily at 09:00, or weekly on Monday at 09:00. -
time_zone: an IANA identifier selected by the user, not a fixed UTC offset. -
next_run_at: the already-resolved UTC instant used for indexed claiming. -
rule_version: incremented whenever the user changes local time, weekday, zone, or enabled state. -
last_occurrence_key: enough state to prevent the same logical occurrence from being created twice.
The scheduler claims records whose next_run_at is due, inserts an occurrence in the same database transaction, advances next_run_at, and commits. A uniqueness constraint on (reminder_id, rule_version, local_date, local_time) turns overlapping scheduler runs into a harmless conflict. The occurrence then moves through the queue independently. This separation matters during recovery: recalculating future time does not rewrite an attempt already promised to a tenant, owner, or maintenance contractor.
A queue is still needed, but it cannot grant exactly-once delivery to an external webhook. The useful target is at-least-once processing plus an idempotency contract. That distinction is easy to blur during a calm design review and painfully obvious during replay.
How should Node.js resolve daily and weekly local-time reminders across DST?
A recurring reminder is a civil-time rule. 09:00 America/New_York expresses user intent; 14:00 UTC expresses one resolved occurrence. Store both forms in the appropriate place: retain the rule and zone on the reminder, then write the resolved UTC instant onto each occurrence. Do not precompute a year of instants, because a rule edit would create a large invalidation problem and a longer trail of obsolete state. Compute the next one after each successful occurrence insert, with a bounded reconciliation scan as a backstop.
DST creates two policy questions, and neither should be left to a library default. A local time can be absent when clocks move forward, or repeated when clocks move back. For an absent time, choose one documented product rule: skip that date or move to the first valid local instant after the gap. For a repeated time, choose the earlier or later instant, but create one logical occurrence. Property-management notices usually need predictability more than astronomical precision, so the policy belongs in user-facing behavior and in tests. I'm not sure which gap policy is correct for every lease or jurisdiction; a product owner and counsel must settle that requirement.
The following Python model is intentionally small because every production runtime, including Node.js, should be tested against the same input-output cases rather than trusted because its date library has a familiar name. The resolver is injected: it is responsible for applying the chosen IANA-zone and DST policy, while this function preserves occurrence identity.
from dataclasses import dataclass
from datetime import date, datetime, time
from typing import Callable, Literal
Cadence = Literal["daily", "weekly"]
@dataclass(frozen=True)
class ReminderRule:
reminder_id: str
version: int
cadence: Cadence
local_time: time
time_zone: str
weekday: int | None = None
def build_occurrence(
rule: ReminderRule,
local_day: date,
resolve_local: Callable[[date, time, str], datetime],
) -> dict[str, str | int]:
if rule.cadence == "weekly" and local_day.weekday() != rule.weekday:
raise ValueError("local_day does not match the weekly rule")
scheduled_at = resolve_local(local_day, rule.local_time, rule.time_zone)
local_label = f"{local_day.isoformat()}T{rule.local_time.isoformat()}"
occurrence_key = f"{rule.reminder_id}:{rule.version}:{local_label}"
return {
"occurrence_key": occurrence_key,
"rule_version": rule.version,
"scheduled_at": scheduled_at.isoformat(),
"time_zone": rule.time_zone,
}
Test the resolver with named cases, not just a broad coverage percentage: an ordinary daily run, a weekly boundary, a spring gap, both candidates in a fall overlap, a time-zone change before dispatch, a rule edit racing a scheduler claim, and two scheduler processes claiming the same due row. Also run fixtures for representative US and EU zones because their transitions need not occur on the same dates. Pin the time-zone-data version used in test and deployment artifacts, then treat an update as a behavior change that reruns these cases.
Short rule: resolve late.
The occurrence key must survive every attempt. Put it in the queue message, the delivery ledger, and an idempotency header understood by the webhook receiver. Never derive it from an attempt number or a queue message identifier. If attempt 1 times out after the receiver commits, attempt 2 must carry the same key so the receiver can return its prior result instead of applying the reminder again.
A worker should claim one occurrence, record an attempt, send the webhook with a bounded timeout, and classify the result. Retry transport failures and explicitly retryable receiver responses with capped exponential backoff plus jitter; move permanent rejections to a review state. The retry ceiling is an operational policy, not a correctness mechanism. Correctness comes from stable identity and deduplication.
AWS SQS illustrates why the ledger remains necessary even when a managed queue hides much of the machinery. Its visibility timeout temporarily prevents other consumers from receiving an in-flight message, yet the documentation states that there is no absolute guarantee a message will not be delivered more than once during that period. Set visibility longer than the normal worker duration, extend it only while useful work continues, and assume a duplicate can still arrive. Don't delete the message until the delivery outcome is durably recorded.
def deliver(message, ledger, webhook):
key = message["occurrence_key"]
prior = ledger.completed_result(key)
if prior is not None:
return {"status": "duplicate_suppressed", "result": prior}
attempt = ledger.begin_attempt(key)
response = webhook.send(
payload=message["payload"],
headers={"Idempotency-Key": key},
timeout_seconds=10,
)
ledger.complete_attempt(attempt.id, response.status_code)
return {"status": "delivered", "code": response.status_code}
This example assumes the receiver honors the idempotency key. If it doesn't, the sender cannot prove exactly-once side effects across a network boundary. For an endpoint under the same organization's control, add a receiver-side table keyed by occurrence key and commit that key with the business update. For a third-party endpoint without such support, expose the residual duplicate risk, reduce retries where the consequence demands it, and provide operators with the occurrence and attempt history.
A recovery drill is the acceptance test
Observability should answer four questions without reconstructing intent from unstructured logs: which local occurrence was due, which UTC instant it resolved to, which rule version produced it, and what the receiver acknowledged. Metrics then summarize that ledger: due-to-enqueued lag, queue age, attempts per occurrence, terminal rejection count, and oldest unresolved occurrence. Break down lag by region and time zone carefully; high-cardinality reminder IDs belong in traces or searchable records, not metric labels.
Use an application-defined correlation path: reminder_id links the rule, occurrence_key links all retries, and attempt_id distinguishes network calls. A log line that contains only a queue message ID is inadequate after redrive because queue identity can change while business identity must not. Alert on aging unresolved occurrences and sustained scheduler lag. Raw worker error counts are secondary; a burst of successful retries may be noisy without violating a delivery objective.
Recovery should be rehearsed. Pause dispatch, run a read-only query for occurrences in the affected time window, compare the ledger with receiver acknowledgements where available, and replay only unresolved keys. Resume ordinary workers after replay has used the same deduplication path as live traffic. Never manufacture fresh keys to get a replay moving; that converts an operational action into a new business event.
Keep a small set of deterministic drills: worker termination after the receiver commits but before queue acknowledgement, queue visibility expiry during a slow request, scheduler overlap, database failover between occurrence insertion and schedule advancement, and deployment across a DST-data update. The expected outcome is not zero retries. It is one logical occurrence, a traceable set of attempts, and no unexplained duplicate side effect.
One warning deserves its own paragraph.
A ledger increases database writes and requires retention governance. It is not suitable when reminders are disposable, duplicates have no consequence, and the team cannot operate transactional state; a simpler best-effort timer may be the honest choice there. At the other extreme, regulated communications may require immutable payload retention and formal reconciliation beyond this compact design. Keep the longer audit trail when contractual proof matters, even though storage is the dominant term you were trying to reduce.
Migrate by generating shadow occurrences first
For Node.js, the component checklist is deliberately unglamorous: a time-zone-aware resolver with explicit overlap and gap controls, a transactional data store with uniqueness constraints and row claiming, a delayed queue with documented redelivery behavior, and an HTTP client with bounded timeouts. Evaluate libraries by running the same DST fixtures and concurrency tests; evaluate queues by visibility, redrive, ordering, delay limits, and operational access to stuck work. Product selection comes after those boundaries are written down.
The main cost lever is retention discipline. Keep enough compact ledger data to cover reconciliation, disputes, and delayed receivers; expire bulky request and response bodies sooner when policy allows; sample successful transport logs while retaining all terminal failures. Storage, queue operations, webhook egress, worker time, and observability ingestion all contribute to the bill, but only measurements from the actual workload can say which comes second.
Ship in stages: generate occurrences without dispatching, compare them with expected local calendars, enable a small time-zone cohort, then expand while watching lag and duplicate suppression. Roll back by stopping new dispatch, not by deleting occurrence history. It's slower than wiring a cron expression straight to an HTTP call. It is also recoverable.
References
Further reading:
- AWS SQS visibility timeout documentation: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- GitHub Actions workflow triggers documentation: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
Top comments (1)
Your approach to managing timezone-aware reminders by storing local-time rules and using a stable occurrence key is both pragmatic and effective. The separation of the scheduling logic from the actual delivery process ensures clarity, especially when dealing with the complexities of DST and idempotency. I particularly appreciate your emphasis on retaining only the necessary state for audit purposes while avoiding bloated snapshots. If you’re looking for additional engineering support as you refine this system, I’d be glad to explore a paid collaboration. What challenges have you encountered with user feedback on the current implementation?