Temporal API Scheduling Without DST Bugs
A wall-clock recurrence model using Temporal types, explicit gap and fold disambiguation, occurrence IDs, missed-run policy, and immutable execution receipts.
Temporal API scheduling works when wall-clock intent and exact execution time are modeled separately. Daylight-saving gaps, duplicated folds, retries, missed runs, and time-zone changes then become explicit policy instead of timestamp surprises.
This tutorial builds that contract around a recurring local time and stable occurrence identity. The fixtures use Bogotá as a control and New York for both offset transitions.
Store Temporal API scheduling as wall-clock intent
Temporal API scheduling should preserve what a person asked for—such as 09:00 every weekday in America/New_York—separately from the exact instant of each execution. A recurrence rule stores local date and time, time zone, calendar, disambiguation, and catch-up policy. Each materialized occurrence then receives an instant and stable identity, allowing retries without rewriting the original wall-clock meaning.
A recurring schedule stores local date, local time, IANA zone, calendar, and disambiguation policy as the user's intent. It stores the resolved instant only for each occurrence. That distinction allows a 09:00 appointment to remain 09:00 after an offset change while an elapsed-duration reminder can remain anchored to exact time instead.
See daylight-saving gaps and folds.
During a spring gap, some local labels do not map to an instant; during a fall fold, one label maps to two. The timeline uses 02:30 and 01:30 examples to expose the missing and duplicated intervals. Compatible, earlier, later, and reject policies are product choices, and a serious scheduler writes the chosen behavior beside the recurrence instead of inheriting it invisibly.
A spring gap contains a wall time that never occurs, while an autumn fold contains two possible instants with the same clock label. The scheduler must choose compatible, earlier, later, or reject deliberately for each product. Silent runtime defaults are not enough because payroll, notifications, and backups can need different answers at the same local timestamp.
Runnable artifact: The policy fixture chooses earlier or later fold instants, handles gaps, and produces stable occurrence keys.
Save this proof as temporal-recurrence.test.mjs and run node temporal-recurrence.test.mjs. Expected final line: PASS: wall-clock recurrence policy.
import assert from "node:assert/strict";
const choose=(candidates,policy)=>{if(candidates.length===1)return candidates[0];if(candidates.length===0){if(policy==="reject")throw Error("gap");return "next-valid"}if(policy==="earlier")return candidates[0];if(policy==="later")return candidates.at(-1);throw Error("fold")};
assert.equal(choose(["2026-11-01T05:30Z","2026-11-01T06:30Z"],"later"),"2026-11-01T06:30Z"); assert.equal(choose([],"compatible"),"next-valid"); assert.throws(()=>choose([],"reject")); const key=(rule,local)=>rule+":"+local; assert.equal(key("ny-0900","2026-03-09"),key("ny-0900","2026-03-09")); console.log("PASS: wall-clock recurrence policy");
Choose the correct Temporal type boundary
PlainDateTime represents a wall label without a zone, ZonedDateTime combines local rules and zone, and Instant names a point on the UTC timeline. The TC39 Temporal documentation and Temporal cookbook explain those tools. The application still decides which type owns user intent, storage, queueing, display, and duration measurement.
The Temporal cookbook demonstrates the type boundaries, and RFC 9557 defines timestamp extensions that can carry zone and offset information. Neither source chooses a missed-run policy for the product. I serialize the original recurrence rule beside each computed occurrence so a future tzdb update can explain why newly resolved instants differ.
Serialize zones and offsets without losing intent.
RFC 9557 defines timestamp extensions that can retain zone annotations and offset information. That makes a wire record more expressive, but recurrence rules still need versioning. A stored occurrence includes original local label, selected zone, resolved offset, exact instant, resolver version, and rule ID so a tzdb correction can be audited rather than mistaken for random drift.
Missed work is classified by usefulness window before catch-up begins. A daily digest may run once with the newest available data, a billing close may replay every occurrence, and a transient reminder may be durably skipped. The executor uses occurrence identity rather than enqueue time as its idempotency key, preventing retries from creating a second logical run.
| Need | Type or record | Preserves | Does not decide |
|---|---|---|---|
| Wall intent | PlainDateTime + zone | Local label | Disambiguation |
| Resolved run | ZonedDateTime | Zone + instant | Catch-up |
| Queue key | Occurrence ID | Logical run | User display |
| Elapsed lease | Monotonic clock | Duration | Calendar |
Define missed-run and duplicate policy
Downtime creates a product question: replay every missed occurrence, coalesce to one newest run, execute only within a usefulness window, or record a skip. The scheduled-jobs missed-run policy supplies a broader framework. The occurrence identity, not delivery attempt, becomes the idempotency key and connects to completion receipts.
A time-zone change can mean the user moved, the organization's policy changed, or tzdb corrected history. I never rewrite past execution receipts; future occurrences are regenerated from the effective rule version and the change is recorded. A preview shows the next five local labels and exact instants before an administrator confirms the new zone.
Work a 09:00 recurrence through offset change. Imagine a weekday report scheduled for 09:00 America/New_York with compatible gap handling, earlier fold handling, and a six-hour usefulness window. The Friday occurrence resolves under daylight time, the Monday after the autumn transition resolves under standard time, and both display 09:00 even though their UTC offsets differ. Temporal API scheduling retains those local labels and gives each date one occurrence ID derived from the rule version and intended local date.
If the queue is down until 17:00 Monday, the six-hour window has closed, so the report is durably skipped rather than delivered as stale morning guidance. A retry at 09:03, by contrast, reuses Monday's occurrence ID and cannot send twice. The ledger can now answer what the user requested, which instant was selected, why delivery was late or skipped, and whether any effect completed.
Handle time-zone and tzdb changes explicitly
Future occurrences are rematerialized when a user's zone or the governing rule version changes; past receipts remain immutable. A preview compares old and new local labels, offsets, and instants before confirmation. Time zones are product state explains why a zone is not merely formatting, especially when organizations schedule work across jurisdictions.
Testing uses fixed tzdb fixtures for Bogotá, New York's gap and fold, a simulated rule change, downtime across several occurrences, and redelivery of one job. Production additionally records runtime and tzdb versions because the same source code can resolve differently after data updates. That provenance turns a surprising timestamp into a traceable decision.
Separate calendar previews from execution leases. Temporal API scheduling should generate a preview using the same resolver that materializes production occurrences. Product copy can show local time, date, zone name, and offset around transitions, while an advanced inspector includes the RFC 9557 representation. A mismatch between preview and executor is a release blocker because users would approve one calendar interpretation and receive another. Snapshots cover every supported zone rule rather than only the developer's machine zone.
Once an occurrence reaches the worker, lease renewal and timeout use monotonic elapsed time; wall-clock correction cannot prematurely expire ownership or extend it indefinitely. The terminal receipt returns to calendar language for user history. Temporal API scheduling therefore crosses three explicit representations—wall intent, exact instant, and monotonic processing duration—without asking one timestamp type to carry all meanings.
Test Bogotá, New York, and restarts
Bogotá provides a no-DST control, while New York supplies deterministic gap and fold fixtures. Additional cases simulate tzdb changes, downtime, concurrent claims, and duplicate delivery. Monotonic clocks and deadlines remain separate: Temporal resolves calendar intent, while a monotonic source measures leases and elapsed processing inside one running system.
Queue clocks and calendar clocks have different jobs. The calendar resolves intended instants, while a monotonic clock measures lease duration and timeout inside one process; substituting wall time for both makes clock correction look like elapsed work. The execution ledger keeps scheduled instant, actual start, completion, outcome, and retry number as separate fields.
Make schedule corrections legible to users. When an administrator changes a rule, the product shows the previous and proposed next occurrences in local and exact form, identifies already queued work, and asks whether those future occurrences should be canceled or preserved. Temporal API scheduling never silently edits history to make a new rule appear retroactive. Notifications name the zone and changed dates when impact is material. This correction workflow treats scheduling as shared product state, giving support and users the same explanation instead of leaving a database timestamp as the only surviving account.
Publish an occurrence ledger people can read
The release artifact prints intended local time, disambiguation, exact instant, attempt, actual start, and terminal outcome for every fixture. Temporal API scheduling passes when reviewers can explain why each row exists and why no duplicate effect occurred. A correct cron string without gap, fold, catch-up, and change semantics is still an incomplete scheduling product.
Temporal.ZonedDateTime is useful when a resolved occurrence must retain its zone-aware calendar representation. DST-safe recurrence also requires explicit gap, fold, and catch-up policy because JavaScript time zones provide rules, not the product decision for ambiguous work.
My release review prints a twelve-month occurrence table around every offset transition in supported zones, then simulates delayed delivery and duplicate claims. Reviewers verify both machine instants and the labels users will see. A schedule ships only when its gap, fold, catch-up, change, and deduplication decisions can be explained without reading library source.
- 1Declare
Wall rule + policy
- 2Resolve
Choose exact instant
- 3Deliver
Deduplicate occurrence
- 4Receipt
Record terminal outcome
Temporal API scheduling becomes reliable when wall intent, exact instants, disambiguation, catch-up, and occurrence identity are separate. Re-run Temporal API scheduling fixtures whenever zones, tzdb, runtime, or missed-run policy changes.