skip to content

On a Temporal.ZonedDateTime, why can zdt.add({ days: 1 }) and zdt.add({ hours: 24 }) give different results, and how does Temporal resolve a wall-clock time that a DST transition erased?

level: seniorimportance: must knowfreq 45%

answer

  1. two frames, not one
  2. calendar units versus stopwatch units
  3. some days are 23 hours long
  4. one clock reading can be missing
  5. the library makes you choose

basics

~20 s

Temporal adds date units in calendar space and time units in exact-elapsed time, so across a DST shift one day may be 23 or 25 real hours. A wall-clock time that no longer exists is resolved by the disambiguation option, which defaults to compatible.

solid answer

~50 s

`ZonedDateTime` arithmetic is deliberately two-tier: date units — days, weeks, months, years — are added in calendar space, so the wall-clock time is preserved and the real elapsed time flexes, while time units — hours and smaller — are added in exact elapsed time, so the wall clock flexes instead. Starting from noon the day before a spring-forward, `add({ days: 1 })` lands on noon the next day, 23 real hours later; `add({ hours: 24 })` lands on 13:00, exactly 24 hours later. Neither is a bug: a daily reminder wants the day form, a session timeout wants the hour form. Where a wall-clock reading is ambiguous or missing — 02:30 on a spring-forward day never happens, 01:30 on a fall-back day happens twice — resolving it takes a `disambiguation` option: `'compatible'` (the default, which pushes forward through a gap and picks the earlier of an overlap), `'earlier'`, `'later'`, or `'reject'`, which throws a RangeError.

code

javascript · 12 lines
javascript
import { Temporal } from '@js-temporal/polyfill';

// New York springs forward at 02:00 on 2026-03-08
const zdt = Temporal.ZonedDateTime.from('2026-03-07T12:00[America/New_York]');

console.log(zdt.add({ days: 1 }).toString());
// 2026-03-08T12:00:00-04:00[America/New_York]  (calendar frame: same clock time)

console.log(zdt.add({ hours: 24 }).toString());
// 2026-03-08T13:00:00-04:00[America/New_York]  (exact frame: 24 real hours)

console.log(zdt.add({ days: 1 }).hoursInDay); // 23

go deeper

for a junior

Know that adding one day and adding 24 hours are not the same thing on a zoned value, because DST makes some days 23 or 25 hours long. Say which of the two you mean when you describe a schedule.

for a middle

Explain the two arithmetic frames by unit — date units in calendar space, time units in exact elapsed time — and be able to walk a concrete spring-forward example showing the offset change that causes the difference.

for a senior

Demonstrate that you map a requirement onto the right frame, and that you handle gaps and overlaps deliberately via the disambiguation option rather than accepting the default everywhere. Have a real incident in mind, such as a drifting nightly job.

for a principal

Own the policy: which product behaviours may silently shift and which must fail loudly, where the canonical zone lives, and how stored values survive tzdata rule changes. Be ready to justify a codebase-wide default and the exceptions to it.

## Two kinds of "the same amount of time" Ask a person what "one day later" means and they will say "same time tomorrow". Ask a stopwatch and it will say "86,400 seconds". On roughly 99% of days those agree. On DST transition days they do not, and every timekeeping bug in this area comes from a library that picked one meaning and hid the choice. Temporal makes both available and separates them by unit. On a `ZonedDateTime`: - **date units** (`years`, `months`, `weeks`, `days`) are added in the calendar/wall-clock frame. The wall-clock reading is preserved; the elapsed exact time is whatever the zone's rules make it. - **time units** (`hours`, `minutes`, `seconds` and smaller) are added in exact time. The elapsed time is exactly what you asked for; the wall-clock reading is whatever the zone's rules make it. When a duration contains both, the date portion is applied first in the calendar frame and the time portion afterwards in exact time. ```js // America/New_York springs forward on 2026-03-08 at 02:00 local const zdt = Temporal.ZonedDateTime.from('2026-03-07T12:00[America/New_York]'); zdt.add({ days: 1 }).toString(); // '2026-03-08T12:00:00-04:00[America/New_York]' — noon again, 23 hours later zdt.add({ hours: 24 }).toString(); // '2026-03-08T13:00:00-04:00[America/New_York]' — 24 real hours, clock reads 13:00 ``` The offset in the output moved from `-05:00` to `-04:00`; that shift is what makes the two answers differ. `Temporal.Instant` has no such split at all: with no zone or calendar it accepts only exact-time units. A related property is `zdt.hoursInDay`, which reports 23, 24 or 25 for the day the value falls on. If your billing or scheduling logic hardcodes 24, that property is where you find out. ## Choosing between them The question to ask is what the requirement is stated in. "Remind me every morning at 09:00" and "invoice on the 1st of each month" are calendar statements: use date units, and the reminder stays at 09:00 through the transition. "Expire this session in 30 minutes" and "retry in 24 hours" are elapsed-time statements: use time units, or better, compute them on `Instant`, where the calendar cannot interfere at all. The classic production bug is a daily job scheduled by adding 86,400,000 milliseconds to a timestamp. It drifts by an hour twice a year, and in a zone that shifts by 30 minutes it drifts by 30. Adding `{ days: 1 }` to a `ZonedDateTime` does not. ## Gaps and overlaps DST creates two ambiguities in the map from wall-clock reading to exact time. A **gap** (spring forward): local clocks jump 02:00 → 03:00, so `2026-03-08T02:30` in `America/New_York` never occurs. An **overlap** (fall back): clocks repeat 01:00 → 02:00 → 01:00, so `2026-11-01T01:30` occurs twice, once at `-04:00` and once at `-05:00`. Whenever you resolve a wall-clock value into a zoned one — `PlainDateTime.prototype.toZonedDateTime`, `Temporal.ZonedDateTime.from` on a string without an offset, or `with()` on the time fields — Temporal consults the `disambiguation` option: - `'compatible'` (default): for a gap, take the later interpretation (shift forward by the gap length); for an overlap, take the earlier of the two. This mirrors what most existing systems do, which is why it is the default. - `'earlier'`: for a gap, shift *backward* by the gap length; for an overlap, take the first occurrence. - `'later'`: for a gap, shift forward; for an overlap, take the second occurrence. - `'reject'`: throw a `RangeError` rather than choose. ```js const gap = Temporal.PlainDateTime.from('2026-03-08T02:30'); gap.toZonedDateTime('America/New_York').toString(); // '2026-03-08T03:30:00-04:00[America/New_York]' — default 'compatible' gap.toZonedDateTime('America/New_York', { disambiguation: 'earlier' }).toString(); // '2026-03-08T01:30:00-05:00[America/New_York]' gap.toZonedDateTime('America/New_York', { disambiguation: 'reject' }); // RangeError ``` The important point for an interview is not memorising the four names but recognising that this is a decision your product has to make, and that Temporal surfaces it as a parameter instead of burying it. A booking system that must never silently move an appointment should pass `'reject'` and handle the error in the UI; a reminder system can happily take the default. The same discipline extends to `zdt.startOfDay()`, which returns the first *existing* instant of that calendar day. In zones that have historically skipped midnight, that is not 00:00, and code that assumed `with({ hour: 0, minute: 0 })` produced the start of the day was wrong there. ## Offsets that no longer match A `ZonedDateTime` string carries both an offset and a zone ID: `2026-11-01T01:30:00-04:00[America/New_York]`. When parsing, the two can disagree — because the value was written before a tzdata rule change, or because it landed on an overlap. `Temporal.ZonedDateTime.from` takes an `offset` option for this: `'reject'` (the default for strings) throws on a mismatch, `'use'` trusts the offset and keeps the exact time while letting the wall clock move, `'ignore'` trusts the wall clock plus the zone, and `'prefer'` uses the offset when it is still valid and otherwise recomputes from the zone. For stored future events, `'prefer'` is usually the intent: keep the appointment where the clock says it is, and accept the new offset if the rules changed. ## What a strong answer sounds like Name the two frames, give the 23-versus-24-hour example, say which requirement maps to which unit, and then show that gaps and overlaps are a separate axis handled by `disambiguation`. Candidates who only say "Temporal handles DST for you" have missed the design: Temporal does not resolve the ambiguity, it makes you resolve it.

  • A nightly job is scheduled by adding 86,400,000 milliseconds to the previous run time. What goes wrong and what do you change?
    It drifts by the DST offset twice a year — the job creeps to 08:00 or 10:00 and stays there until the next transition, and in half-hour-offset zones it drifts by 30 minutes. Compute the next run as `zdt.add({ days: 1 })` on a `ZonedDateTime` in the business's zone so the wall-clock time is preserved, and convert to an instant only when you actually arm the timer.
  • Which disambiguation setting would you choose for a booking system, and why not the default?
    `'reject'`. The default `'compatible'` silently moves a booking that lands in a DST gap, which means a customer is told one time and the system holds another. Rejecting surfaces the impossible slot as a RangeError you can turn into a UI message. Use the default only where a silent one-hour shift is genuinely acceptable, such as informational reminders.
  • Why does Temporal.ZonedDateTime expose an hoursInDay property?
    Because a calendar day in a zone with DST is 23, 24 or 25 hours long, and any code that divides or fills a day — timesheets, capacity bars, hourly grids — needs the real number. Hardcoding 24 produces an off-by-one row twice a year, which is exactly the kind of defect that only reproduces on two days of the year.
  • When resolving a stored future ZonedDateTime string whose offset no longer matches the zone rules, which offset option applies?
    Pass `{ offset: 'prefer' }` to `Temporal.ZonedDateTime.from`. It honours the stored offset while it is still valid and otherwise recomputes from the zone ID, keeping the wall-clock time the user booked. The default `'reject'` throws on a mismatch, `'use'` keeps the old exact time and lets the clock shift, and `'ignore'` discards the offset entirely.

saying these in an interview costs you the question

  • A day is always 24 hours, so both forms of add agree
  • Temporal removes DST ambiguity so you never have to decide
  • Adding 86400000 milliseconds is the same as adding one day
  • A wall-clock time always maps to exactly one instant
  • Storing the offset instead of the zone ID is enough for future arithmetic

context