skip to content

A JavaScript scheduler computes "the same time tomorrow" by adding `24 * 60 * 60 * 1000` to a Date. When does that produce the wrong wall-clock time, and what would you do instead?

level: seniorimportance: should knowfreq 50%

answer

  1. a local day is not always 24 hours
  2. elapsed time versus calendar time
  3. setDate keeps the wall clock
  4. some local times never happen
  5. store the rule, not the instant

basics

~20 s

Adding 86,400,000 milliseconds adds exactly 24 hours of elapsed time, but a local day is 23 or 25 hours long across a daylight-saving transition. On those days the wall-clock time drifts by an hour, so calendar arithmetic must use setDate(), not millisecond addition.

solid answer

~60 s

Millisecond arithmetic on a Date is arithmetic on an instant: `t + 86400000` is exactly 24 hours later, which is correct if you meant "one day of elapsed time". A *calendar* day in a DST-observing zone is 23 or 25 hours long, so on a spring-forward day a 09:00 reminder becomes 10:00 and on a fall-back day it becomes 08:00. For calendar semantics use the local field setters — `d.setDate(d.getDate() + 1)` — because they rewrite the local wall-clock fields and let the engine recompute the instant with whatever offset applies. Two further hazards: a wall-clock time inside a spring-forward gap does not exist, and the engine silently normalises it to some real instant, so `getHours()` may not return what you set; and a time inside the fall-back hour is ambiguous, matching two instants. For recurring schedules, do not store an instant at all — store the local time plus the zone identifier and expand each occurrence when it is due, so a legislature changing DST rules does not shift every future occurrence.

code

javascript · 12 lines
javascript
// Run with TZ=America/New_York, near the 9 March 2025 transition.
const nineAm = new Date(2025, 2, 8, 9, 0);

const byMillis = new Date(nineAm.getTime() + 24 * 60 * 60 * 1000);
console.log(byMillis.getHours()); // 10 — 24h elapsed, but the day was 23h

const byCalendar = new Date(nineAm);
byCalendar.setDate(byCalendar.getDate() + 1);
console.log(byCalendar.getHours()); // 9 — same wall clock, next date

console.log(nineAm.getTimezoneOffset(), byCalendar.getTimezoneOffset());
// 300 240 — differing offsets prove a transition sits between them

go deeper

for a junior

Know that adding 24 hours in milliseconds is not the same as moving to tomorrow, because daylight-saving days are 23 or 25 hours long, and that setDate() is the calendar-preserving way.

for a middle

Explain why the field setters recompute the instant from local fields while millisecond addition moves the instant directly, and show the two symptoms: an hour of drift in each direction across the two annual transitions.

for a senior

Diagnose the twice-a-year one-hour drift from its signature, name the gap and overlap hazards including zones where local midnight does not exist, and insist on tests pinned to a DST-observing zone with a transition date in the fixtures.

for a principal

Set the system-wide rule for future-dated and recurring events: store wall time plus an IANA zone and resolve late, so that a legislature changing DST rules does not silently shift every stored occurrence.

## Two different kinds of "a day" There are two operations people call "add a day", and they are not the same: - **Duration arithmetic** — advance the instant by 86,400 seconds of elapsed time. Correct for timeouts, rate limits, token lifetimes, SLA windows. - **Calendar arithmetic** — keep the wall-clock time and move to the next date on the calendar. Correct for reminders, business days, billing periods, "every morning at 09:00". In a zone with no DST the two coincide, which is why the bug survives development and appears twice a year. ## Why millisecond addition drifts ```js // Host in America/New_York; DST starts 09 March 2025 at 02:00 local. const d = new Date(2025, 2, 8, 9, 0); // Sat 8 Mar, 09:00 EST const plusMs = new Date(d.getTime() + 24 * 60 * 60 * 1000); plusMs.getHours(); // 10 <- Sun 9 Mar, 10:00 EDT const plusDay = new Date(d); plusDay.setDate(plusDay.getDate() + 1); plusDay.getHours(); // 9 <- Sun 9 Mar, 09:00 EDT ``` The millisecond version is not wrong about elapsed time — 24 hours really have passed — it is wrong about the *question*. Because the local day was only 23 hours long, 24 hours of elapsed time lands an hour past the same wall clock. In autumn the day is 25 hours long and the same addition lands an hour early. The field setters behave differently by design: `setDate()` writes local calendar fields and the engine then recomputes the time value using the offset in force for that local date. That is precisely the semantics "same wall clock, next day". The same distinction governs *differences*. `(b - a) / 86400000` gives elapsed days, not calendar days, so it can produce 0.958 or 1.042 across a transition. If you want "how many calendar days apart", normalise both to local midnight first and round, or compare the local year/month/day fields directly. ## Times that do not exist and times that happen twice DST transitions do more than lengthen or shorten a day. **The spring gap.** When clocks jump 02:00 to 03:00, no instant has a local reading of 02:30. Asking for one is asking for something impossible: ```js const gap = new Date(2025, 2, 9, 2, 30); // America/New_York gap.getHours(); // not 2 — the engine normalises to a real instant ``` The engine picks a real instant rather than throwing or returning an Invalid Date; which one it picks is not something to build on. This matters for user input: a form that lets someone pick 02:30 on a transition date has captured a time that does not exist, and no library can fix that — the interface has to. Worse, in some zones the gap swallows midnight itself. Where a transition happens at 00:00 local, there is no local midnight on that date at all, so an idiom like `d.setHours(0, 0, 0, 0)` to get "start of day" produces 01:00 rather than 00:00. Code that assumes the day starts at hour 0 will be off in those zones. **The autumn overlap.** When clocks fall back 02:00 to 01:00, every local time in that hour matches two distinct instants an hour apart. A Date built from local fields can only be one of them, and the API gives you no way to say which. Two log lines timestamped 01:30 local can be an hour apart in reality. ## Diagnosing it in production The signature is unmistakable once you know it: a job that fires on time all year drifts by exactly one hour starting on a transition date, in one direction in spring and the other in autumn, and only for users in DST-observing zones. Practical checks: - Look for `86400000`, `3600000`, or `* 24 * 60 * 60 * 1000` in scheduling code — each is a place where duration and calendar semantics were conflated. - Compare `getTimezoneOffset()` on the two endpoints; if they differ, a transition sits between them. - Reproduce by running the service with `TZ` set to a DST-observing zone and the clock near a transition date, rather than in a UTC container where the bug is invisible. ## What to do instead 1. **Decide which semantics you mean, per call site.** Duration for timeouts and expiry; calendar for anything a human reads as a date or a time of day. 2. **Use the field setters for calendar arithmetic** — `setDate`, `setMonth`, `setFullYear` — and millisecond addition only for durations. 3. **Do not store an instant for a future recurrence.** Store `{ localTime: '09:00', zone: 'America/New_York', rule: 'daily' }` and compute the next instant when you need it. Time-zone rules change by legislation several times a year; an instant computed last year encodes a rule that may no longer hold, while a stored wall time plus zone stays correct. 4. **Handle the gap and the overlap at the input edge.** Reject or disambiguate a local time that falls in a gap; for the overlap, capture the offset the user meant alongside the wall time. 5. **Test with a fixed non-UTC zone.** A test suite pinned to UTC cannot see any of this. Pin `TZ` to a DST-observing zone for the date-sensitive tests, and include a transition date in the fixtures.

  • Which of the two approaches is correct for a token that must expire 24 hours after issue?
    Millisecond addition. An expiry is a duration — the token should be valid for exactly 24 hours of elapsed time regardless of what the local clock does, so `issuedAt + 86400000` is right and calendar arithmetic would grant or steal an hour twice a year.
  • How would you compute how many calendar days apart two Dates are, given that dividing the difference by 86400000 is unsafe?
    Normalise both to the start of their local day, then divide and round — or better, compare the local year/month/day fields directly. Raw division yields fractional values across a DST transition, so a naive floor can report 0 for two dates that are genuinely a day apart.
  • Why is storing a UTC instant for a meeting scheduled a year ahead risky, and what do you store instead?
    Because the conversion bakes in today's time-zone rules. If the zone changes its DST dates or offset before the meeting — governments do this with a few months' notice — the stored instant now maps to the wrong local time. Store the wall-clock time plus an IANA zone identifier and resolve the instant when the occurrence is due.
  • What happens when you build a Date for a local time that falls inside a spring-forward gap?
    The instant does not exist, so the engine normalises the fields to some real instant instead of throwing or returning an Invalid Date. Reading the hour back may not give what you set. Because which instant is chosen is not something to depend on, the right fix is at the input edge: prevent or explicitly disambiguate the selection.

saying these in an interview costs you the question

  • Believes every local day is exactly 24 hours long
  • Uses (b - a) / 86400000 to count calendar days
  • Thinks setting an hour in the DST gap throws or yields Invalid Date
  • Stores a computed UTC instant for a recurring future event
  • Tests date logic only in a UTC container

context