Most JavaScript date bugs don't show up on ordinary weekdays. They show up on the two Sundays a year when a civil day is not 24 hours long. In the US, those Sundays are the spring-forward (clocks jump ahead) and fall-back (clocks repeat an hour). If your tests assume "one day == 86,400,000 ms," they will pass everywhere except the days that matter.
This article walks through those transitions for America/New_York in 2026, with every snippet run on Node.js v24.22.1 (TZ=America/New_York). typeof Temporal is undefined in this runtime, so the runnable examples use Date and Intl. Temporal's design goals are cited from the TC39 and MDN docs at the end — not from invented console output.
The 2026 US transitions (verified)
IANA rules for the US since 2007: spring forward on the second Sunday in March, fall back on the first Sunday in November, both at 02:00 local (Rule US ... Mar Sun>=8 / Nov Sun>=1). For America/New_York, zdump -v on this machine reports:
Sun Mar 8 06:59:59 2026 UT = Sun Mar 8 01:59:59 2026 EST (UTC−05)
Sun Mar 8 07:00:00 2026 UT = Sun Mar 8 03:00:00 2026 EDT (UTC−04)
Sun Nov 1 05:59:59 2026 UT = Sun Nov 1 01:59:59 2026 EDT (UTC−04)
Sun Nov 1 06:00:00 2026 UT = Sun Nov 1 01:00:00 2026 EST (UTC−05)
So 2026-03-08 is a 23-hour civil day (the 02:00–02:59 hour is skipped), and 2026-11-01 is a 25-hour civil day (01:00–01:59 occurs twice).
Intl agrees when you format the same UTC instants:
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
timeZoneName: "shortOffset",
});
console.log(fmt.format(new Date("2026-03-08T06:59:59Z")));
// Sun, Mar 8, 2026, 01:59:59 GMT-5
console.log(fmt.format(new Date("2026-03-08T07:00:00Z")));
// Sun, Mar 8, 2026, 03:00:00 GMT-4
console.log(fmt.format(new Date("2026-11-01T05:59:59Z")));
// Sun, Nov 1, 2026, 01:59:59 GMT-4
console.log(fmt.format(new Date("2026-11-01T06:00:00Z")));
// Sun, Nov 1, 2026, 01:00:00 GMT-5
Measure the civil day: midnight to midnight
A reliable check is the elapsed milliseconds between consecutive local midnights — not "add 24 hours to noon."
function localMidnight(y, m0, d) {
return new Date(y, m0, d, 0, 0, 0, 0); // requires TZ=America/New_York
}
function hoursBetween(a, b) {
return (b - a) / 3_600_000;
}
console.log(hoursBetween(localMidnight(2026, 2, 7), localMidnight(2026, 2, 8))); // 24
console.log(hoursBetween(localMidnight(2026, 2, 8), localMidnight(2026, 2, 9))); // 23
console.log(hoursBetween(localMidnight(2026, 10, 1), localMidnight(2026, 10, 2))); // 25
Actual printout:
Sun Mar 08 2026 → Mon Mar 09 2026: 82800000 ms = 23 hours
Sun Nov 01 2026 → Mon Nov 02 2026: 90000000 ms = 25 hours
If a scheduler, cron helper, or "daily digest" window hard-codes 24 * 60 * 60 * 1000, it will drift by one hour on each of these Sundays.
Adding 24 hours ≠ adding one calendar day
This is the classic footgun. Across the spring transition:
const start = new Date(2026, 2, 8, 1, 30, 0, 0); // Mar 8 01:30 EST
const plus24h = new Date(start.getTime() + 24 * 60 * 60 * 1000);
const plusCalDay = new Date(start);
plusCalDay.setDate(plusCalDay.getDate() + 1);
console.log(start.toString());
// Sun Mar 08 2026 01:30:00 GMT-0500 (Eastern Standard Time)
console.log(plus24h.toString());
// Mon Mar 09 2026 02:30:00 GMT-0400 (Eastern Daylight Time)
console.log(plusCalDay.toString());
// Mon Mar 09 2026 01:30:00 GMT-0400 (Eastern Daylight Time)
console.log(plus24h.getTime() === plusCalDay.getTime()); // false
Across the fall transition the same split appears the other way:
const start = new Date(2026, 10, 1, 0, 30, 0, 0); // Nov 1 00:30 EDT
const plus24h = new Date(start.getTime() + 86_400_000);
const plusCalDay = new Date(start);
plusCalDay.setDate(plusCalDay.getDate() + 1);
console.log(plus24h.toString());
// Sun Nov 01 2026 23:30:00 GMT-0500 (Eastern Standard Time)
console.log(plusCalDay.toString());
// Mon Nov 02 2026 00:30:00 GMT-0500 (Eastern Standard Time)
Rule of thumb: use calendar arithmetic (setDate / date-only fields) when you mean "tomorrow at the same wall time"; use a fixed millisecond duration when you mean "exactly 24 hours of elapsed time." They only coincide on 24-hour civil days.
Skipped local times (spring)
On 2026-03-08, local 02:30 never exists. MDN documents that Date follows Temporal's disambiguation: "compatible" behavior: a gap is resolved by moving forward by the gap length.
const skipped = new Date(2026, 2, 8, 2, 30, 0, 0);
console.log(skipped.toString());
// Sun Mar 08 2026 03:30:00 GMT-0400 (Eastern Daylight Time)
console.log(skipped.getHours(), skipped.getMinutes()); // 3 30
Neighbors confirm the gap:
01:59: Sun Mar 08 2026 01:59:00 GMT-0500 (Eastern Standard Time)
03:00: Sun Mar 08 2026 03:00:00 GMT-0400 (Eastern Daylight Time)
Any parser that accepts 2026-03-08T02:30 as a New York local time without a policy is lying to itself. Decide whether to reject, clamp forward, or clamp backward — and test it.
Ambiguous local times (fall)
On 2026-11-01, 01:30 occurs twice: once in EDT and once in EST. Date's compatible rule picks the earlier instant (still on daylight time):
const amb = new Date(2026, 10, 1, 1, 30, 0, 0);
console.log(amb.toString());
// Sun Nov 01 2026 01:30:00 GMT-0400 (Eastern Daylight Time)
console.log(amb.getTimezoneOffset()); // 240 (EDT = UTC−4)
Stepping through UTC makes the repeated hour visible:
2026-11-01T05:30:00.000Z → Sun Nov 01 2026 01:30:00 GMT-0400 (EDT)
2026-11-01T06:00:00.000Z → Sun Nov 01 2026 01:00:00 GMT-0500 (EST)
2026-11-01T06:30:00.000Z → Sun Nov 01 2026 01:30:00 GMT-0500 (EST)
If you store "local wall time" without an offset or a fold bit, you cannot round-trip the second 01:30.
A small test table you can paste into CI
These six checks all passed on the same Node run (TZ=America/New_York):
| Check | Expected | Observed |
|---|---|---|
| Mar 7 length | 24h | 24h |
| Mar 8 length | 23h | 23h |
| Nov 1 length | 25h | 25h |
new Date(2026,2,8,2,30) |
maps to 03:30 EDT | 03:30 EDT |
new Date(2026,10,1,1,30) |
earlier (EDT, offset 240) | offset 240 |
+24h vs setDate(+1) on Mar 8 01:30 |
different instants | different |
Keep the zone explicit (TZ=… in the process, or always format with timeZone: "America/New_York"). Host-local tests on a machine in Asia/Seoul or UTC will never exercise US DST gaps.
Where Temporal fits (and why it isn't in these run outputs)
On this Node v24.22.1 binary, typeof Temporal === "undefined". No Temporal polyfill was available offline, so this article does not invent Temporal console output.
What Temporal is designed to fix is exactly the confusion above. Per the TC39 Temporal proposal and MDN's Temporal overview:
-
Temporal.Instantis a unique point on the timeline (like a better epoch nanosecond stamp). -
Temporal.PlainDateTimeis a wall-clock date/time without a zone — the "read it off a calendar" value. -
Temporal.ZonedDateTimepairs calendar fields with a named zone and exposeshoursInDay,startOfDay(), and explicitdisambiguationwhen a local time is skipped or repeated.
In other words: Instant answers "when in absolute time?"; Plain* answers "what does the clock/calendar say?"; ZonedDateTime is the bridge that knows DST. Legacy Date tries to wear both hats at once, which is why + 86400000 and "add one day" silently diverge.
Until Temporal is available in your runtime, you can still write correct tests with Date + Intl — as long as you pin the zone, measure civil-day length, and assert both the gap and the fold.
Practical checklist
- Pick real transition dates from IANA/
zdumpfor the zones you support (here: 2026-03-08 and 2026-11-01 in New York). - Assert civil-day length is 23h / 25h across those midnights.
- Assert
+ 24hand calendar+ 1 daydiverge. - Assert skipped times resolve the way your product promises (Date's default: jump forward).
- Assert ambiguous times pick a documented fold (Date's default: earlier / DST side).
- Prefer storing UTC instants or offset-aware strings; treat bare local times as incomplete.
Sources
-
MDN:
Date(gap/fold = Temporalcompatible) - MDN:
Intl.DateTimeFormat - MDN:
Temporal - TC39 Temporal proposal
-
IANA: How to Read the tz Database (US
Mar Sun>=8/Nov Sun>=1rules)
I built a small two-city time zone converter, WorldTimeSync: https://time.wooooju.com/en/
Top comments (0)