JavaScript Date kept leaving me off by a day. How does Temporal in Node 26 solve it?
My couples app's D-day count was off by a day, so I looked at the design problems in JavaScript's Date and how Temporal's PlainDate, ZonedDateTime and Duration in Node 26 fix it.
#Node #JavaScript #Temporal #Dates
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.