Temporal is on by default in Node 26, and DST is the reason to care
Date picks an answer when a wall clock time does not exist or happens twice, and never tells you. Temporal makes that choice a parameter you can see.
Comments
Loading comments
Temporal is on by default in Node 26, which shipped on May 5. No flag.1 The usual pitch for Temporal is that it's a nicer Date and you can finally drop moment, date-fns or luxon. Sure. But the nicer API is not why I think it matters. It matters because Date makes decisions about daylight saving time without telling you, and Temporal makes you make them.
I live in India. Asia/Kolkata has no DST, so the clock here never jumps or repeats, and code that gets this wrong runs perfectly on my laptop. That's a comfortable place to never learn any of this, and a bad place to trust your own tests from.
Some wall clock times don't exist, and some happen twice
In a zone with DST, two days a year are weird. In spring the clock skips an hour: in New York on March 8, 2026 it goes from 1:59am straight to 3:00am, so 2:30am never happens. In autumn it repeats an hour: on November 1, 1:30am happens, the clock falls back, and 1:30am happens again. "1:30am on November 1" doesn't name one moment. It names two.
Date has no way to say "that time doesn't exist" or "which one?". It just picks.
To check, I wrote a Node 26 script with no dependencies and no flags. Date can't take a time zone, it only understands the process's local zone, so I ran the script with TZ=America/New_York and built the missing time with new Date(2026, 2, 8, 2, 30, 0) (month 2 is March, because months start at zero, because of course they do):
07:30Z is 3:30am in New York once the clocks have moved to UTC-4. I asked for 2:30am, a time that didn't happen that day, and got 3:30am back. No error, no warning, nothing on the object that says a choice was made. Call getHours() on it and you get 3, and nothing about that looks suspicious.
Temporal makes the choice a parameter
Temporal.ZonedDateTime.from takes a wall clock time plus a zone, and it has a disambiguation option with four values: "compatible" (the default), "earlier", "later" and "reject".2 Same 2:30am, same script, all four:
compatible gives the same instant Date did, and that's on purpose. The Temporal docs say "compatible" matches legacy Date behaviour: it acts like "later" when the clock jumps forward and like "earlier" when it falls back.3 So switching to Temporal with the defaults won't change your answers. It just puts the decision somewhere you can see it.
The fall-back morning shows the other half. These are two rows from the script stepping through November 1 in 30 minute steps (I dropped the reject column, which threw on both):
1:00am and 1:30am each happen twice, an hour apart. Date, compatible and earlier all take the first one, and later takes the second. Taking the first one isn't wrong. It's a reasonable default. But it is a decision, and for some code the two instants are not interchangeable: a billing cutoff, a log you'll sort later, a job that must not run twice. With Date you can't even tell the decision happened. With Temporal you can pick "reject" and handle the error, or pick "later" because you actually mean the second one.
"Add a day" is not "add 24 hours"
The second thing IST hides from you: "add a day" and "add 24 hours" feel like the same operation, because here they always give the same answer. They aren't the same operation. Temporal's ZonedDateTime.add uses calendar arithmetic for date units like days and months, and real elapsed time for time units like hours and minutes.3 A DST change is exactly where those stop agreeing.
Starting at 00:30 on New York's spring-forward day:
add({ days: 1 }) keeps the wall clock at 00:30, which is what "same time tomorrow" means, and only 23 real hours pass because the offset moved from -05:00 to -04:00. add({ hours: 24 }) keeps the duration exact and lands at 01:30, because one of those clock hours never happened. The two results are exactly one hour (3,600,000 ms) apart.
Neither one is the correct answer in general. A daily 9am reminder wants days. A "you can try again in 24 hours" rate limit wants hours. The bug is when you meant one and wrote the other, and in a zone without DST you'll never see it.
Not a rewrite, a habit
What surprised me when I ran the script is how boring it is the rest of the time. On every other half hour of those days, all four modes give the same instant. They only split at the moments where something has to guess, and that's where Date guesses quietly and Temporal makes the guess a named option.
I'm not going to go rewrite every new Date() in old code. That's not a great use of anyone's weekend. But for new code, especially anything that stores a wall clock time and a zone and rebuilds an instant from them later (schedules, reminders, "every day at 9am local time"), I'd use Temporal.ZonedDateTime and set disambiguation on purpose, even if the value is just "compatible". The point isn't the nicer syntax. It's that the choice becomes a word in your code instead of a default that only shows up in a timezone you don't live in.
TC39 Temporal docs, `Temporal.ZonedDateTime` (opens in new tab): "compatible" acts like "earlier" for backward transitions and "later" for forward ones and matches legacy Date; add() uses calendar arithmetic for the date part of a duration and real-world time for the time part. ↩
Time zone
Day, 2026
Clocks jump from 02:00 to 03:00
00:0006:0012:0018:0024:00
exists once
does not exist
happens twice
02:30 on 2026-03-08 in New York does not exist. The clock skips it, so something has to pick a time near it.
Same wall time: yes. They part only across the change.
02:30 does not exist. Date gives 03:30 -04:00, Temporal compatible gives 03:30 -04:00.
Try it. Drag the clock through 02:00 on March 8 in New York, switch the mode, and watch where Date and Temporal part. Offsets come from your browser's time zone data; the two rules are the specs' own.
Comments