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
Recurring schedules are where the wall-clock-versus-absolute-time distinction stops being academic and starts costing real money and missed meetings. A "daily 9am standup" means 9am on the participants' wall clocks every day β including the day after the clocks shift. If you implement "daily" as "add 24 hours" on a millisecond timestamp, then on the morning after a spring-forward the meeting silently moves to 10am, and after a fall-back it moves to 8am, for every recurrence going forward. The schedule drifts by the transition amount and stays drifted, which is exactly the failure users notice and trust least.
The fix is to advance by a calendar unit on a zone-aware type: zdt.add({ days: 1 }) keeps the wall-clock time fixed across the transition, so 9am stays 9am regardless of whether the intervening night was 23 or 25 hours long. This is the behavior human recurrence rules assume. The subtlety that remains is what to do when the recurring wall time lands on a transition β a 2:30am job on a spring-forward day when 2:30am does not exist β which requires a disambiguation decision rather than a silent guess.
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 production recurrence generator advances the wall-clock anchor by calendar units and resolves transition landings explicitly. Starting from a ZonedDateTime, each occurrence is anchor.add({ days: n }) (or weeks/months for other cadences), which preserves the wall-clock time by construction. For the rare occurrence that falls in a spring-forward gap or fall-back overlap, the disambiguation option chosen at construction β 'compatible', 'earlier', 'later', or 'reject' β decides how the impossible or doubled wall time resolves. Making that a stated policy means the one weird day a year behaves predictably instead of surprising you.
For cadences longer than a day the same principle applies with the matching calendar unit: { weeks: 1 } for weekly, { months: 1 } for monthly β and monthly recurrence inherits the month-end overflow decision (does the 31st clamp to the 30th?) exactly as ordinary date arithmetic does. Compute each occurrence from the original anchor rather than by repeatedly stepping the previous result, so that a one-off clamp or transition adjustment on a single occurrence does not permanently shift every later one. Anchoring to the origin keeps the series aligned to the intended wall-clock time for the whole horizon.
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
The assertion that proves correctness is a recurrence that straddles a daylight-saving transition: generate several days of a 9am daily event across a spring-forward date and assert every occurrence reads 9am on the wall clock, even though the absolute gaps between them are not all exactly 24 hours. Do the same across a fall-back date. This directly demonstrates that calendar-unit advancement holds the wall time fixed where hour-based advancement would drift it.
Add a transition-landing case: schedule a recurrence whose wall time falls in the spring-forward gap and assert your disambiguation policy resolves it as intended rather than throwing unexpectedly (or throwing deliberately under 'reject'). For longer cadences, assert a monthly recurrence anchored on the 31st behaves per your chosen overflow policy in short months. Run the generator under several TZ values and confirm the produced wall-clock times are identical, proving the schedule is anchored to the event's own zone and not the host's.
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
The dominant pitfall is advancing a recurrence by a fixed duration β 24 hours, 7Γ24 hours β instead of by a calendar unit, which drifts the wall-clock time across every DST transition. Use { days: 1 }, { weeks: 1 }, { months: 1 } on a ZonedDateTime so the wall time is preserved. The second is ignoring transition landings: a recurring wall time that hits a spring-forward gap or fall-back overlap needs an explicit disambiguation policy, or you get an unexpected throw or a silent doubled/skipped occurrence.
A third mistake is generating occurrences by stepping the previous result, which lets a single adjusted occurrence shift the entire remaining series; compute each from the original anchor instead. A fourth is storing recurrences as UTC instants and reconstructing local times with a hard-coded offset, which is wrong for half the year β persist the zoned anchor and the rule, and expand occurrences with zone-aware arithmetic. Finally, remember that "floating" events (a wellness reminder at 9am in whatever zone the user is currently in) are a deliberately different model from a zoned recurrence, and conflating them produces reminders at the wrong local time after travel.
-
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.
How do I schedule a daily recurring event that stays at the same wall-clock time across DST?
Advance the anchor by a calendar unit on a ZonedDateTime: zdt.add({ days: 1 }). Because that preserves the wall-clock time, a 9am daily event stays at 9am even on the day after the clocks shift, when the night was 23 or 25 hours long. Advancing by { hours: 24 } instead would drift the event by the transition amount, moving it to 10am or 8am and keeping it there.
What happens if a recurring event lands on a daylight-saving transition?
The wall time may not exist (spring-forward gap) or may occur twice (fall-back overlap), so you resolve it with a disambiguation option set at construction: 'compatible' picks a sensible instant, 'earlier'/'later' choose a side of the overlap, and 'reject' throws so you can handle it explicitly. Set this policy deliberately so the one transition day a year behaves predictably instead of surprising you with a skipped or doubled occurrence.
Should I compute each recurrence from the previous one or from the original start?
Compute each occurrence from the original anchor β anchor.add({ days: n }) β rather than by repeatedly stepping the previous result. Deriving from the origin means a one-off clamp (a month-end) or transition adjustment on a single occurrence does not permanently shift every later one, so the series stays aligned to the intended wall-clock time across the whole horizon.