tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000) is a line that turns up in a lot of scheduling and billing code. It looks harmless, and for most of the year it is. Twice a year, in any timezone that observes daylight saving, it's wrong by an hour.
Node.js 26, released in May, turns on the Temporal API without a flag. The release notes describe it as a "modern date/time API for JavaScript that provides a more robust and feature-rich alternative to the legacy Date object." I pulled the official image, ran the naive pattern next to Temporal.ZonedDateTime.add(), and the drift showed up on the first try.
I wanted to know what that default actually buys a working codebase, not just what the API looks like in isolation, so everything below is a real run against the official node:26 and node:24 images, not a reading of the spec.
The drift, measured
I picked the 2026 US spring-forward (2026-03-08, clocks in America/New_York jump from 02:00 to 03:00) and started both calculations from the same instant: 2026-03-07 at 22:00 local time.
const TZ = 'America/New_York';
const startUtcMs = Date.parse('2026-03-08T03:00:00.000Z'); // 22:00 local, pre-jump
// the naive "add a day" pattern
const naiveNextDayMs = startUtcMs + 24 * 60 * 60 * 1000;
// Temporal's calendar-aware equivalent
const zdt = Temporal.Instant.fromEpochMilliseconds(startUtcMs).toZonedDateTimeISO(TZ);
const nextDay = zdt.add({ days: 1 });
| result | |
|---|---|
| start (local wall clock) | 03/07/2026, 22:00:00 |
Date + 24h, read back as local time |
03/08/2026, 23:00:00 |
Temporal zdt.add({days: 1})
|
2026-03-08T*22:00:00*-04:00 |
The Date version lands an hour later than it should, because adding 24 hours of UTC time walks straight through the hour that the clocks skipped. Temporal keeps the wall-clock time fixed at 22:00 and lets the UTC offset move from -05:00 to -04:00 instead, which is what "the same time, one calendar day later" actually means to a person. That's the whole value proposition in one run, and it's the first thing that didn't need three attempts to show up.
What it costs you per call
Correct math isn't free. I ran a million iterations of "take an instant, add a second to it, read a field back" for both APIs, three times each, on an otherwise idle container:
| trial |
Date (ms) |
Temporal (ms) |
|---|---|---|
| 1 | 65.4 | 2071.5 |
| 2 | 72.8 | 1839.7 |
| 3 | 66.5 | 1913.1 |
Roughly 27 times slower, consistently, not a one-off. That number felt too clean, so I split the loop to find out which half of the operation was actually responsible, rather than trust the combined figure.
// add-only, no field read
for (let i = 0; i < N; i++) sum += inst.add({ seconds: i }).epochMilliseconds;
Isolated, .add() alone was 59.6ms for Date against 1152.9ms for Temporal — about 19 times slower on its own. Reading a field off an already-built ZonedDateTime (zdt.hour) was a smaller gap, 11.5ms against 71.9ms. So the earlier combined number wasn't an artefact of my timing window catching something unrelated; .add() really is the expensive part, and it's expensive by design, not by my mistake. Temporal objects are immutable and carry full calendar and timezone context through every operation, where Date is just a wrapped 64-bit number.
The overflow behaviour I assumed wrong
I went in expecting Temporal to be the strict, no-silent-surprises alternative to Date's habit of normalising invalid input. new Date(2026, 1, 30) quietly becomes March 2nd, because February doesn't have a 30th day and Date just keeps counting. I assumed Temporal.PlainDate.from() would refuse that outright.
new Date(2026, 1, 30).toISOString().slice(0, 10)
// '2026-03-02'
Temporal.PlainDate.from({ year: 2026, month: 2, day: 30 })
// 2026-02-28 -- no error
It doesn't throw. The default overflow mode is "constrain", which clips the out-of-range day down to the last valid one instead of raising anything. It's a different kind of wrong to Date's (clipping to the 28th is arguably more defensible than rolling into March), but it is still silent, and that contradicted what I expected walking in. Getting a real error needs an explicit option:
Temporal.PlainDate.from({ year: 2026, month: 2, day: 30 }, { overflow: 'reject' })
// Uncaught RangeError: Temporal error: day value is not in a valid range.
A genuinely malformed string throws without needing that option, so the strictness is there, just not where I first looked for it:
Temporal.PlainDate.from('2026-13-40')
// RangeError: Temporal error: Parsed month value not in a valid range.
Temporal.ZonedDateTime.from('2026-01-01T00:00:00+00:00[Europe/Nonexistent]')
// RangeError: Temporal error: Unknown time zone identifier
If any of your code treats "no exception" as "the date was valid," Temporal's default construction from a plain object won't give you that for free. You still need overflow: 'reject'.
Everything prior to Node 26 still needs a flag
Node 24, the LTS line Node 26 will eventually replace, doesn't have Temporal at all without an explicit opt-in:
$ node -e "Temporal.Now.instant()"
ReferenceError: Temporal is not defined
Adding --harmony-temporal on Node 24.21.0 gets you the same global, and in my checks it behaved identically to Node 26's default — same overflow rules, same error text, same arithmetic results. So the actual change in 26 is narrower than "Temporal arrived": it was already there, behind a flag, since at least the 24.x line, sitting on V8 13.6. What 26 changes is who gets it without reading the release notes first, and it comes bundled with the V8 jump to 14.6 rather than a Temporal-specific rewrite. The flag works the other way too: Node 26 exposes --no-harmony-temporal if you need Temporal to stay undefined, which matters if a library you depend on polyfills the name and would conflict with the native one.
I also checked whether there's anything to watch while this runs in production — a counter, a log line, a flag you can read back at runtime. process.features doesn't mention it at all, and process.config.variables does list v8_enable_temporal_support and v8_enable_temporal_systemicu, both 1 on the official image — but those are compile-time build flags, not a live state: they stayed 1 even when I ran the same process with --no-harmony-temporal, which does turn the global off. So neither surfaces the actual runtime toggle. The only reliable check from inside a process is typeof Temporal, which is a reasonable thing to assert in a startup health check if you're migrating code onto it deliberately.
No startup tax for the part you don't use
Turning a global on by default raised an obvious question: does every Node 26 process now pay for Temporal even if it never touches it? I spawned twenty bare node -e "1" processes with the default flags and twenty more with --no-harmony-temporal, timing each with process.hrtime.bigint() around the spawn:
| min | median | mean | |
|---|---|---|---|
| default (Temporal on) | 22.99ms | 28.59ms | 29.24ms |
--no-harmony-temporal |
24.93ms | 31.65ms | 30.78ms |
| difference | within noise | within noise | within noise |
No measurable gap, and if anything the "off" runs were marginally slower, which is noise, not a real effect. That lines up with the per-operation cost I measured above: you pay for Temporal when you call it, not for having it switched on.
What I got wrong on the way
My first version of the performance benchmark folded the field read (.toZonedDateTimeISO('UTC').hour) into the same loop as the .add() call, for both Date and Temporal. The combined number — 27x — was real, but I couldn't tell from it whether the slowdown was in the arithmetic or in extracting a usable value afterwards, and a reader could reasonably ask the same thing. Splitting the two apart (the add-only and field-only benchmarks above) was the right move: it showed .add() carries almost all of the cost, and it meant I wasn't reporting a number I couldn't explain.
Run it yourself
Everything above ran against the official node:26 and node:24 Docker images.
docker pull node:26
docker pull node:24
# the DST drift
cat > dst.js <<'EOF'
const TZ = 'America/New_York';
const startUtcMs = Date.parse('2026-03-08T03:00:00.000Z');
console.log('naive +24h:', new Date(startUtcMs + 86400000)
.toLocaleString('en-US', { timeZone: TZ, hour12: false }));
const zdt = Temporal.Instant.fromEpochMilliseconds(startUtcMs).toZonedDateTimeISO(TZ);
console.log('Temporal add({days:1}):', zdt.add({ days: 1 }).toString());
EOF
docker run --rm -v "$PWD":/work -w /work node:26 node dst.js
# Temporal behind a flag on 24, same result as 26's default
docker run --rm node:24 node --harmony-temporal -e \
"console.log(Temporal.Now.instant().toString())"
docker run --rm node:24 node -e "Temporal.Now.instant()" # throws without the flag
A grep for 86400000 or 24 * 60 * 60 * 1000 in a billing or scheduling codebase takes about five minutes, and the measured gap above is the reason to spend it: anywhere that pattern adds a calendar day rather than a fixed span of milliseconds, it will be wrong on the next DST boundary it crosses, by exactly the hour I measured. Rewriting one into Temporal is a small change; finding them all is the part that takes longer than five minutes in practice.
Don't rewrite a hot path on the strength of this post alone, though. A log parser or a bulk date-range walk doing this a few million times a run will feel the 19x, and Date is still correct for anything measured in raw milliseconds rather than calendar days — the bug only shows up when you cross a DST boundary doing day-level arithmetic, which most timestamp processing never does. And if you do reach for Temporal.PlainDate.from() to validate user input, pass overflow: 'reject' by hand. Without it, a bad day gets clipped rather than flagged, and I'd have shipped that assumption wrong if I hadn't checked it directly.
Top comments (19)
Nope, that part didn't change! When 1:30am happens twice, Temporal defaults to the earlier one (EDT, -04:00), same as Date. The difference is you actually get a say:
{ disambiguation: "later" }gives you the second 1:30, and"reject"throws so you know it was ambiguous. The drift in the post is more about the arithmetic and spring-forward stuff.The overflow: 'constrain' section is what I'd worry about most in billing code, and it has a twin on the DST side. Converting a wall-clock time that doesn't exist uses disambiguation: 'compatible' by default, so on node:26-alpine
Temporal.PlainDateTime.from('2026-03-08T02:30').toZonedDateTime('America/New_York')quietly becomes 03:30-04:00. Pass{ disambiguation: 'reject' }and you getRangeError: Temporal error: Rejecting ambiguous time zones.instead. The fall-back hour is the opposite case: 2026-11-01T01:30 exists twice, and the default picks the earlier -04:00 one. For user-entered schedule times I now set both overflow and disambiguation to 'reject' and show the error.oh nice, that's a great twin to call out. spring forward quietly turning 02:30 into 03:30 is exactly the kind of thing nobody notices until a customer gets billed at the wrong time. reject on both and showing the error is the right call for user input imo, I'd much rather ask "that time doesn't exist, pick another" than guess. might add the disambiguation bit to the post, thanks!
Glad it's useful! One more thing worth putting in the post if you add that section: if you store the result, keep the zone ID next to the instant (for example
America/New_York) and not only the offset. A saved-04:00turns wrong for a recurring schedule once the clocks change, and you can't tell which of the two 01:30s the user meant. Are you storing schedules as ZonedDateTime strings or as instant plus zone?yeah exactly, offset-only is the trap. a stored -04:00 is a lie the moment the clocks move.
▎
▎ depends on the schedule type for me. one-off events i store as instant plus zone id. recurring ones i actually store the wall clock plus the zone id (basically a PlainDateTime like 09:00 plus America/New_York) and resolve to an instant at fire time, because "every day at 9am" has no fixed instant, the offset literally changes twice a year. if you freeze an instant for a recurring job it drifts an hour after every DST flip.
Same split here, and resolving at fire time is the only version that survives DST. The leftover edge is a recurring job whose wall clock lands in the gap or the overlap: in America/New_York a daily 02:30 doesn't exist on spring-forward day, and a daily 01:30 happens twice on fall-back day.
'compatible'runs the first at 03:30 and the second once, at the earlier 01:30, which is usually what people expect. I'd pin that choice in a test though, so nobody switches it to'reject'later and quietly drops a run twice a year.yep, the gap and the overlap are the whole ballgame. and you nailed what compatible does: spring-forward 02:30 pushes to 03:30, fall-back 01:30 fires once at the earlier instant. that lines up with what people actually mean by "every day at 2:30."
the test is the real tip though. compatible vs reject isn't a style choice, it's a behavior switch, and reject throws right in the scheduler on exactly the two days a year nobody's watching. a cron that silently skips a run every spring is a miserable bug to chase. so yeah, pin the disambiguation in a test with a known gap date and a known overlap date, assert the fire times, done. nice thread, this turned into a proper little DST checklist.
Good week to come back to this: Europe fell back this morning, and New York does it on 1 November. Those two dates are the fixtures I'd pin.
Temporal.ZonedDateTime.from({ timeZone: 'America/New_York', year: 2026, month: 11, day: 1, hour: 1, minute: 30 }, { disambiguation: 'compatible' })should give the -04:00 instant, and the same call for 2026-03-08 02:30 should land on 03:30. Assert the offset too, not just the hour, since both 01:30s print the same wall clock. Late reply on my side, the thread got buried.haha perfect timing, Europe literally just did it. and yep both check out, the fall-back 01:30 comes back as 2026-11-01T01:30:00-04:00 and the spring-forward 02:30 lands on 2026-03-08T03:30:00-04:00.
asserting the full string is the easy way to get the offset in there for free:
assert.equal(fallBack.toString(), '2026-11-01T01:30:00-04:00[America/New_York]')
assert.equal(springFwd.toString(), '2026-03-08T03:30:00-04:00[America/New_York]')
and the fall-back one is the sneaky one, an hour-only check passes for both 01:30s, so without the offset the test would happily pass even if someone flipped it to 'later'. good call on that.
no worries on the late reply, this thread's been a good one.
Great post — the bit about
overflow: 'constrain'being the default is the kind of thing that quietly bites people, so thanks for writing it down. I'll admit I also assumed it would throw!One thing to add from the i18n side: the same wall-clock-versus-absolute-time split shows up in rendering, not just in the arithmetic. If you take the
Date + 24hresult and hand it toIntl.DateTimeFormat, it will faithfully print the shifted wall-clock time — so the user sees a booking that no longer matches the time they actually chose. That's the drift made user-visible, not just an internal bookkeeping error.ZonedDateTimeplugs directly intoIntl.DateTimeFormat.format()(the 402 integration accepts Temporal types), so you can feed the very object you did the arithmetic on straight into your formatter. Display and scheduling stay on the same model, and localising into other timezones or calendars doesn't reintroduce the drift at the render layer.One small note on the grep advice: it catches literal
86400000constants, but also watch for helper functions that do then * 86400000math internally — those won't show up when you grep for the constant itself.Thanks! And yeah the overflow: 'constrain' thing got me too lol
Small catch on the Intl part though: I tested it on Node 26 and Intl.DateTimeFormat.format() actually throws a TypeError if you pass it a ZonedDateTime. That's on purpose in the spec, so the formatter's timeZone and the object's zone can't fight each other. What works is zdt.toLocaleString("en-US", {...}), which uses the object's own zone. Same idea though: format the exact object you did the math on and the drift can't sneak back in at render time.
Good call on the helpers too, grep for 86400000 won't catch a daysToMs() buried somewhere. Worth grepping for 864e5 and 24 * 60 * 60 too
Ahh, good catch — thanks for actually testing that! I had the formatter integration slightly wrong in my head. Using the object's own toLocaleString is much nicer anyway than juggling a separate timeZone option that could disagree.
And 864e5 plus the spelled-out 24 * 60 * 60 are going on my mental checklist — I've definitely been burned by a helper named something innocent like delayMs() that was just hiding the multiplication inside.
haha yeah the innocent-looking helpers are the worst, delayMs(1) and suddenly you're 24 hours off and nobody can see why. grep for 864e5 once and you'll find more than you'd like
Haha, exactly — the sneaky part is that delayMs(1) reads perfectly at the call site, so nobody ever suspects it. And the bug it produces is 24 hours off, which is just plausible enough to survive code review for months. Grepping for 864e5 is going straight onto my mental checklist — great tip!
lol "just plausible enough" is the scariest kind of bug. cheers!
Great write-up. The part that stuck with me is that Temporal fixes Date's type confusion (instant vs. calendar math) without fixing the validation burden by default. The overflow: 'constrain' behavior means a bad input still silently clips to the last valid day, so the clean model seems to be: use overflow: 'reject' at input boundaries where an invalid date is a genuine error, and accept the lenient default in UI-driven code where clipping to Feb 28th is honestly the desired behavior. One question on the ~19x overhead on .add(): in a hot scheduling path, would you cache the computed ZonedDateTime, or is the absolute per-call cost small enough that it rarely matters in practice?
yeah that's pretty much how I'd split it too, reject at the edges and constrain where the user's poking at a date picker. on the 19x: it's relative to Date's plain ms math, which is basically free, so in absolute terms you're still talking microseconds per call. for most scheduling code that's noise next to a db round trip. if it ever shows up in a profile I'd keep the hot loop in Temporal.Instant / epoch math and only go through ZonedDateTime where you actually need calendar rules (DST, "same time next month"). they're immutable so caching one is safe, but I'd measure before bothering
Really thoughtful post! Documenting real-world engineering hurdles and actionable solutions like this brings immense value to the community.
Thank you! I try to post content that brings value to the community!