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?
answer
- two frames, not one
- calendar units versus stopwatch units
- some days are 23 hours long
- one clock reading can be missing
- the library makes you choose
basics
~20 sTemporal 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 linesimport { 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); // 23go deeper
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.
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.
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.
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