Recurring Event Scheduling Across DST
To keep a recurring event at the same wall-clock time across daylight-saving transitions, advance a Temporal.ZonedDateTime with .add({ days: 1 }) (calendar arithmetic), never .add({ hours: 24 }) (absolute arithmetic) — and choose a disambiguation policy for the days when that local time falls in a gap or overlap. This page is part of Working with ZonedDateTime Objects.
Why this scenario is tricky
A "9:00 AM standup" means 9:00 AM every day on the wall clock — the thing people read off their phone. But the wall clock is not a uniform line of absolute time. Twice a year, daylight saving shifts the local offset, so the number of absolute seconds between consecutive 9:00 AMs is not always 86,400.
If you schedule by adding 24 hours of absolute time, the event drifts. After a spring-forward (clocks jump 02:00 → 03:00), 24 absolute hours after Saturday 09:00 lands on Sunday 10:00 local. After a fall-back (clocks repeat 01:00 → 01:00), it lands on Sunday 08:00. Either way the standup has silently moved, and it stays moved until the next transition shoves it back. The deeper mechanics of why offsets shift live in Timezone Offset Math Explained.
Calendar arithmetic fixes the drift: .add({ days: 1 }) on a ZonedDateTime advances the calendar day and re-anchors to the same local time in the target zone, absorbing whatever offset change occurred. But that introduces a second problem — some wall-clock times do not exist or exist twice on transition days. A 02:30 daily event has no 02:30 on spring-forward day, and two 01:30s on fall-back day. Temporal forces you to decide what happens, via the disambiguation option.
The timeline below contrasts the two arithmetic models across a spring-forward boundary.
API reference
| Expression | What it advances | Effect across DST |
|---|---|---|
zdt.add({ days: 1 }) |
Calendar day | Holds wall-clock time; absolute gap may be 23h/24h/25h |
zdt.add({ hours: 24 }) |
Absolute time | Holds absolute gap; wall-clock drifts by the offset change |
disambiguation: 'compatible' |
Gap/overlap policy | Gap → push later; overlap → earlier instant (default) |
disambiguation: 'earlier' |
Gap/overlap policy | Picks the earlier of the two instants |
disambiguation: 'later' |
Gap/overlap policy | Picks the later of the two instants |
disambiguation: 'reject' |
Gap/overlap policy | Throws RangeError on a nonexistent/ambiguous time |
Minimal working solution
Generate the next N daily occurrences by repeatedly adding one calendar day. The wall-clock time stays put; the offset (-08:00 vs -07:00) flips automatically across the transition.
import { Temporal } from '@js-temporal/polyfill';
// Daily 09:00 standup the day before US spring-forward (2026-03-08).
const start = Temporal.ZonedDateTime.from(
'2026-03-07T09:00:00-08:00[America/Los_Angeles]'
);
let cursor = start;
for (let i = 0; i < 3; i++) {
console.log(cursor.toString());
cursor = cursor.add({ days: 1 }); // calendar add holds the 09:00 wall-clock
}
// 2026-03-07T09:00:00-08:00[America/Los_Angeles] (PST)
// 2026-03-08T09:00:00-07:00[America/Los_Angeles] (PDT — offset flipped, 23h later)
// 2026-03-09T09:00:00-07:00[America/Los_Angeles]
Every line reads 09:00 local even though the second hop spans only 23 absolute hours. Swapping in .add({ hours: 24 }) would print 10:00 on the second line.
Full production version
A real scheduler must support a recurrence interval, validate the zone, and decide what to do when the requested local time hits a spring-forward gap or fall-back overlap. The generator below re-anchors each occurrence by calendar unit and lets the caller pick the disambiguation policy.
import { Temporal } from '@js-temporal/polyfill';
type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
interface RecurrenceOptions {
start: string; // e.g. '2026-03-07T02:30:00-08:00[America/Los_Angeles]'
count: number;
unit: 'days' | 'weeks';
disambiguation?: Disambiguation;
}
export function recurringOccurrences(opts: RecurrenceOptions): Temporal.ZonedDateTime[] {
const { start, count, unit, disambiguation = 'compatible' } = opts;
let cursor: Temporal.ZonedDateTime;
try {
cursor = Temporal.ZonedDateTime.from(start); // requires an [IANA] zone in the string
} catch {
throw new Error(`Invalid ZonedDateTime start: "${start}"`);
}
const step = unit === 'weeks' ? { weeks: 1 } : { days: 1 };
const out: Temporal.ZonedDateTime[] = [];
for (let i = 0; i < count; i++) {
out.push(cursor);
// overflow:'reject' guards calendar rollover (e.g. day 31); disambiguation
// guards DST gaps/overlaps that 'days'/'weeks' arithmetic can land on.
cursor = cursor.add(step, { overflow: 'reject', disambiguation });
}
return out;
}
// A 02:30 daily event hits the spring-forward gap on 2026-03-08.
const series = recurringOccurrences({
start: '2026-03-07T02:30:00-08:00[America/Los_Angeles]',
count: 3,
unit: 'days',
disambiguation: 'later', // 02:30 doesn't exist on 03-08 → snap to 03:30 PDT
});
series.forEach((z) => console.log(z.toString()));
// 2026-03-07T02:30:00-08:00[America/Los_Angeles]
// 2026-03-08T03:30:00-07:00[America/Los_Angeles] (gap → pushed to next valid instant)
// 2026-03-09T02:30:00-07:00[America/Los_Angeles]
With disambiguation: 'earlier' the gap day would instead resolve to 01:30 PST (the last valid instant before the jump). 'reject' would throw, which is the right choice when a missing local time is a data error rather than something to silently snap.
Verification snippet
These assertions prove the wall-clock-vs-absolute distinction and the fall-back overlap behavior.
import { Temporal } from '@js-temporal/polyfill';
const start = Temporal.ZonedDateTime.from(
'2026-03-07T09:00:00-08:00[America/Los_Angeles]'
);
// Calendar add holds wall-clock; the spring-forward day is only 23 absolute hours.
const nextDay = start.add({ days: 1 });
console.assert(nextDay.hour === 9, 'wall-clock 09:00 preserved');
const absHours = start.until(nextDay, { largestUnit: 'hours' }).hours;
console.assert(absHours === 23, `spring-forward day spans 23h, got ${absHours}`);
// Absolute add drifts the wall clock to 10:00.
const drifted = start.add({ hours: 24 });
console.assert(drifted.hour === 10, 'absolute +24h drifts to 10:00');
// Fall-back overlap: 01:30 exists twice on 2026-11-01; 'earlier' picks PDT (-07).
const overlap = Temporal.PlainDateTime
.from('2026-11-01T01:30:00')
.toZonedDateTime('America/Los_Angeles', { disambiguation: 'earlier' });
console.assert(overlap.offset === '-07:00', 'earlier overlap = PDT');
console.log('All DST scheduling assertions passed');
Common pitfalls
-
Adding absolute time for a wall-clock series. Wrong:
zdt.add({ hours: 24 })for a daily event drifts ±1h at each transition. Right:zdt.add({ days: 1 }), which re-anchors to the same local time. -
Ignoring the gap day. Wrong: assuming
02:30exists every day — on spring-forward day it does not, and the default'compatible'policy silently moves it. Right: pick'earlier','later', or'reject'deliberately and document it. -
Materializing occurrences from a UTC instant + offset. Wrong: storing
start.toInstant()and re-adding days to theInstant—Instanthas no zone, so calendar days are meaningless. Right: keep theZonedDateTime(or store the IANA zone alongside) and do the arithmetic there. -
Mishandling month-end weekly/monthly steps. Wrong: silently letting day 31 + 1 month constrain to the 30th. Right: pass
overflow: 'reject'(or'constrain') explicitly so rollover is a decision, not a surprise.
Frequently Asked Questions
Why does adding one day sometimes change the UTC offset?
Because ZonedDateTime.add({ days: 1 }) advances the calendar day and then resolves the same local time in the zone. If a DST transition fell between the two days, the local offset changes (e.g. -08:00 → -07:00), so the same 09:00 corresponds to a different UTC instant — exactly what keeps the wall clock stable.
What disambiguation should I use for recurring events?
Use 'compatible' (the default) for forgiving consumer scheduling — it pushes gap times forward and picks the earlier instant on overlaps. Use 'reject' when a nonexistent or ambiguous local time signals bad input you'd rather catch. 'earlier'/'later' give you explicit control on transition days.
How many absolute hours are between two consecutive daily occurrences?
Usually 24, but 23 on spring-forward day and 25 on fall-back day. Compute it with prev.until(next, { largestUnit: 'hours' }) rather than assuming 86,400 seconds.