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.

Calendar add versus absolute add across spring-forward Adding 24 hours of absolute time after spring-forward lands at 10am local; adding one calendar day holds 9am local. Spring-forward Sunday (clocks skip 02:00 → 03:00) Sat 09:00 local start .add({ days: 1 }) Sun 09:00 local wall-clock held (23h absolute) .add({ hours: 24 }) Sun 10:00 local drifted +1h — wrong

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

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.