DEV Community

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

Alex Georgiev on October 10, 2026

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 fo...
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
 
amorizz profile image
Amorizz •

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 get RangeError: 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.

Collapse
 
alexgeorgiev17 profile image
Alex Georgiev •

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!

Collapse
 
amorizz profile image
Amorizz •

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:00 turns 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?

Thread Thread
 
alexgeorgiev17 profile image
Alex Georgiev •

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.

Thread Thread
 
amorizz profile image
Amorizz • • Edited

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.

Thread Thread
 
alexgeorgiev17 profile image
Alex Georgiev •

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.

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.

Thread Thread
 
alexgeorgiev17 profile image
Alex Georgiev •

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

Thread Thread
 
emmawingding profile image
Emma Wingding •

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!

Thread Thread
 
alexgeorgiev17 profile image
Alex Georgiev •

lol "just plausible enough" is the scariest kind of bug. cheers!

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
 
alexgeorgiev17 profile image
Alex Georgiev •

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

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!