DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Node.js 26's default Temporal API fixes a one-hour DST drift that Date still has

Benchmarking reveals a 19x performance cost

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 });
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 (8)

Collapse
 
emmawingding profile image
Emma Wingding •

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 + 24h result and hand it to Intl.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.

ZonedDateTime plugs directly into Intl.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 86400000 constants, but also watch for helper functions that do the n * 86400000 math internally — those won't show up when you grep for the constant itself.

Collapse
 
alexgeorgiev17 profile image
Alex Georgiev •

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

Collapse
 
emmawingding profile image
Emma Wingding •

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.

Collapse
 
argumentmoney8117 profile image
ArgumentMoney8117 •

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?

Collapse
 
kyisaiah47 profile image
kyisaiah47 •

Did the fall-back case change the result when a local time occurred twice, or did Temporal pick one occurrence by default?

Collapse
 
alexgeorgiev17 profile image
Alex Georgiev •

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.

Collapse
 
dev_in_the_fog profile image
Jason Y. (dev_in_the_fog) •

Really thoughtful post! Documenting real-world engineering hurdles and actionable solutions like this brings immense value to the community.

Collapse
 
alexgeorgiev17 profile image
Alex Georgiev •

Thank you! I try to post content that brings value to the community!