JavaScript Date kept leaving me off by a day. How does Temporal in Node 26 solve it?
My couples app counts how many days it has been since the day we started dating (the D-day count), and one day I noticed it was off by one day. It was Date again. JavaScript's Date lets you down by a hair every time you work with dates. I heard the Temporal API ships by default in Node 26, so I took the chance to look at it properly.
In this post I go over why Date was so awkward and how Temporal fixed it. I also write down why I still haven't been able to put it on my own server.
First, why it was off by a day
The symptom was simple. I opened the app on the day of our 100-day anniversary and it said 99 days. The day we met was stored in the DB as a DATE type, and on the screen I was subtracting that day from today and dividing by one day's worth of milliseconds.
// How I first wrote it const meet = new Date(coupleInfo.meetDay); // Value from the server. I thought it was only a date const days = Math.floor((Date.now() - meet.getTime()) / 86400000);
Two problems were stacked on top of each other. One was where the count starts. The day we met should count as day 1, but I was counting it as day 0. The other was the time of day. A DATE column looks like it only holds a date, but the moment the server sends it down as JSON it becomes a string with a time attached, and when you build a new Date from that again, you get a time that isn't midnight, depending on the time zone. Subtract that from the current time and the two sides have different midnights, so you get a fraction, and floor drops a day.
For now I've patched it by setting both sides to midnight before subtracting, then adding 1. This is the actual code.
// Fixes the mismatch where "99 days" showed up on the 100-day anniversary.
const calculateDaysTogether = () => {
if (!coupleInfo?.meetDay) return null
const meetDate = new Date(coupleInfo.meetDay)
meetDate.setHours(0, 0, 0, 0) // The day we met, at local midnight
const todayMs = new Date().setHours(0, 0, 0, 0) // Today at local midnight too
return Math.floor((todayMs - meetDate.getTime()) / 86400000) + 1 // The day we met = day 1
}I fixed it, but it's the kind of code where I have to leave a comment explaining why setHours is called twice and why there's a +1, or future me won't understand it. Having to know things like this every time is the problem with Date.
Date's design itself is outdated
Date wasn't awkward because I didn't know how to use it. It was because the design was wrong. Counting only the best-known traps, there are four.
First, months start at 0. January is 0 and December is 11. new Date(2026, 5, 14) is June 14. This one thing keeps you making mistakes for life.
Second, it's mutable. Calling date.setMonth() changes the original object. If some code quietly changes a date, every other place that referenced it goes wrong along with it. That's a really hard bug to track down.
Third, it has no time zones. Date really only knows two things, "local time" and "UTC". Even when you only want to work with a date, a time always tags along, and the D-day problem above came from exactly this. To handle another country's time zone properly, you had to bring in a separate library.
Fourth, parsing does whatever it wants. Turning a string into a Date behaves inconsistently from one environment to another, so code that "seemed to work" breaks somewhere else.
What Temporal fixed
Temporal reworked these problems at the design stage. It answers the four above one by one.
January is 1. It's exactly how people think of it. And it's immutable, so an object doesn't change once it's created. Adding to a date gives you a new object. The accident where the original quietly changes can't happen by construction.
Time zones are handled properly from the start. There's no bolting a time zone on later and ending up off. And the types are split by purpose. A plain date with no time zone is PlainDate, a timestamp for a specific moment is Instant, one with a time zone attached is ZonedDateTime, and a length of time is Duration. What you're working with shows in the type, so you don't get confused.
// A plain date. No time, no time zone
const d = Temporal.PlainDate.from('2026-06-14');
d.month; // 6 (it's 6, not 0)
d.add({ months: 1 }); // Returns a new object. d stays the same
// A date and time with a time zone attached
const seoul = Temporal.ZonedDateTime.from('2026-06-14T09:00[Asia/Seoul]');
seoul.withTimeZone('America/New_York').toString();
// -> '2026-06-13T20:00:00-04:00[America/New_York]'
// A duration
const dur = Temporal.Duration.from({ hours: 26 });
dur.total({ unit: 'days' }); // 1.0833...The D-day calculation got this clean
Before, to get the difference between two dates I subtracted in milliseconds and divided by 86400000, and a time zone or daylight saving time would throw it off by a day. Temporal gives you this exactly, as a Duration.
// Old way: subtract milliseconds. Once a time zone gets involved, it's off by a day
const days = Math.floor((today - start) / (1000 * 60 * 60 * 24));
// Temporal: subtracting one date from another gives an exact Duration
const start = Temporal.PlainDate.from('2024-03-01');
const today = Temporal.Now.plainDateISO(); // Today's date only. No time
const dday = start.until(today).days; // How many days it has been
// Going the other way, "days until the next anniversary" works the same
const next = start.add({ years: 3 });
today.until(next).days;Because the math is between PlainDates, "time" never gets involved. Daylight saving time and time zones have no effect on it. For calculations where only the date matters, like a D-day count, PlainDate with the time information left out is a perfect fit. The whole business of forcing things to midnight with setHours, like I did above, stops being necessary.
Built in from Node 26
Until now Temporal could only be used through a polyfill (a stand-in implementation you install separately), but from Node 26 it is built into the V8 engine by default. Take the polyfill out and Temporal is just there. On the browser side, Chrome and Firefox are catching up too, so it really is time to start using it.
With the polyfill, you had to start like this. Being built in means this one line goes away.
// Before Node 26, like this
import { Temporal } from '@js-temporal/polyfill';
// From Node 26, it's just there as a global
Temporal.Now.plainDateISO();I still haven't put it on the server
To use Temporal the runtime has to support it, and this is where my server is right now. It's still the same value in September 2026, as I'm revising this post.
$ node -v v18.19.1
Several services run together under PM2 on one machine, so upgrading Node means restarting all of them at once. If one of them doesn't start on the new version, the rest stop along with it. This blog actually went down entirely once because I installed one wrong version of sharp, so I'm even more cautious about changing the runtime itself.
So for now I only put defensive code in the places where dates matter. The D-day calculation above is exactly that kind of defensive code. When I do upgrade, it's better to do it in one go at a low-traffic hour, and before that I plan to check each service one by one to see whether it starts on the new version. If you're starting a new project, I'd recommend starting on Node 26 and writing it with Temporal from the beginning.