The Temporal API Ships Enabled in Node 26: Stop Fighting With Date
Table of Contents
Nine years after it was proposed, the Temporal API is here and needs no flag. I installed Node 26 purely to verify it before writing this:
$ node -v
v26.7.0
$ node -e "console.log(typeof Temporal)"
object
All nine classes, available out of the box:
Now, PlainDate, PlainTime, PlainDateTime, ZonedDateTime,
Duration, Instant, PlainYearMonth, PlainMonthDay
Every example in this article was executed on that version. When you see output, it’s real output — not what the docs say should happen.
Where Temporal actually stands
Being precise about status matters, because there are articles overstating it in both directions:
| Status as of August 2026 | |
|---|---|
| Standard | Stage 4 since March 2026. Part of ES2026 |
| Node | 26: enabled by default. Enters LTS in October |
| Chrome / Edge | Supported since 144 (January 2026) |
| Firefox | Supported since 139 |
| Safari | Technology Preview only, behind a flag |
| Baseline | No, and Safari is the blocker |
Two nuances that get reported wrong:
Dateis not deprecated. MDN describes Temporal as “a full replacement” forDate, which isn’t the same thing.Dateremains part of the standard and will keep working. There’s no retirement date.- In the browser you still need a polyfill, because Safari doesn’t ship it in stable. On the server, with Node 26, you don’t.
Put simply: on the backend you can use it as-is; on the frontend, with a polyfill.
The four Date flaws Temporal fixes
These aren’t cosmetic. They’re the ones that produce bugs which reach production.
1. Months start at zero
The classic. Still there since 1995:
new Date(2026, 8, 7) // → 2026-09-07 😐
Temporal.PlainDate.from({year:2026, month:8, day:7}) // → 2026-08-07 ✅
Verified output. The 8 means September in Date and August in Temporal, which is what anyone would expect.
2. The string format changes the time zone
This is the one that does the most damage, and almost nobody knows it. Run on a machine in America/Mexico_City (UTC−6):
new Date("2026-08-07") // → Thu Aug 06 2026 18:00:00 ⚠️ the previous day!
new Date("2026/08/07") // → Fri Aug 07 2026 00:00:00
Same date, two different separators, two different days. The reason is that Date interprets the hyphenated ISO format as UTC, and any other format as local time. Sitting six hours behind UTC, UTC midnight lands at 18:00 the day before.
If you have a form that submits 2026-08-07 and you store the result, you just lost a day. It’s the origin of half the “it shows a day earlier” bugs in any project.
Temporal.PlainDate.from("2026-08-07") // → 2026-08-07, full stop
A PlainDate has no time zone, so there’s nothing to interpret. That’s the underlying fix: separating “a calendar date” from “an instant in time,” two things Date conflates.
3. Date is mutable
const d = new Date(2026, 0, 15);
d.setMonth(5);
// d changed: → 2026-06-15 the original object is modified
Versus:
const d = Temporal.PlainDate.from('2026-01-15');
d.add({months: 5});
// d is still → 2026-01-15 add() returns a new one
Everything in Temporal is immutable. If you pass a date into a function, nobody is going to change it underneath you.
4. Adding a month to January 31
Here Date does something genuinely indefensible:
const d = new Date(2026, 0, 31);
d.setMonth(d.getMonth() + 1);
// → 2026-03-03 😱 March 3rd?
Since February 31 doesn’t exist, Date overflows: it counts 31 days from February 1 and lands in March. Silently.
Temporal forces you to decide what you want:
Temporal.PlainDate.from('2026-01-31').add({months: 1})
// → 2026-02-28 by default it clamps to the last valid day
Temporal.PlainDate.from('2026-01-31').add({months: 1}, {overflow: 'reject'})
// → throws RangeError
That overflow option is the difference between a serious date library and one that guesses. In a billing app with monthly due dates, Date’s default behavior generates invoices with the wrong date and nobody notices until a customer complains.
The case that really matters: time zones
Everything above is fine, but this is what justifies migrating.
Chile, and the noon that arrives at one
A real scenario: an appointment on Saturday September 5 at 12:00 in Santiago, rescheduled “two days later.” Chile enters daylight saving time on the 6th.
I verified the offset change day by day:
2026-09-04 offset -04:00
2026-09-05 offset -04:00
2026-09-06 offset -03:00 ← DST begins
2026-09-07 offset -03:00
And the same calculation with both APIs, starting from the identical instant:
Start (both): 05-09-26, 12:00 | UTC 2026-09-05T16:00:00.000Z
Temporal .add({days: 2}): 07-09-26, 12:00 ← noon, as the user expects
Date +48h in milliseconds: 07-09-26, 13:00 ← one hour late
Temporal understands that “two days later” means the same wall-clock time two days later, and that achieving it takes only 47 real hours, not 48:
zdt.until(withTemporal, {largestUnit: 'hour'}).hours
// → 47
Date can’t do this because it knows nothing about time zones: it only knows how to add milliseconds. And 48 × 3600 × 1000 milliseconds is 48 hours, always, even when the calendar says otherwise.
Every time someone writes date.getTime() + days * 86400000, they’re making this mistake. It works eleven months a year.
Mexico, which stopped changing the clocks
The mirror image of the problem. Mexico abolished daylight saving in 2022, and the data reflects it:
Temporal.ZonedDateTime.from('2026-01-15T12:00:00[America/Mexico_City]').offset // → -06:00
Temporal.ZonedDateTime.from('2026-07-15T12:00:00[America/Mexico_City]').offset // → -06:00
The same offset in January and July. If you’re carrying code that assumes Mexico shifts the clock — or worse, a hardcoded -05:00 for summer — you’ve been miscalculating half the year for four years.
Temporal reads the system time zone database, so these political changes arrive with OS updates and there’s nothing to maintain.
Which class to use
Nine classes look intimidating at first, but the decision is mechanical:
| What you need to represent | Class |
|---|---|
| A calendar date, no time | PlainDate |
| A wall-clock time, no date | PlainTime |
| Date and time, no zone | PlainDateTime |
| Date and time in a specific place | ZonedDateTime |
| An exact point on the timeline | Instant |
| A span (3 months and 4 days) | Duration |
| A month of a year (card expiry) | PlainYearMonth |
| A day of the year (birthday) | PlainMonthDay |
| The current time | Now |
The practical rule: if the value makes sense without knowing where you are, it’s a Plain. A date of birth is a PlainDate; the moment an email was sent is an Instant; a meeting is a ZonedDateTime.
That distinction between PlainDate and Instant is the one Date never had, and nearly all its problems came from there.
Conversion table from date-fns and dayjs
All of this executed and verified on Node 26.7.0:
| What you used to write | With Temporal | Real output |
|---|---|---|
addDays(d, 5) | d.add({days: 5}) | 2026-08-12 |
subDays(d, 10) | d.subtract({days: 10}) | 2026-07-28 |
differenceInDays(a, b) | a.until(b, {largestUnit:'day'}).days | 13294 |
intervalToDuration() | a.until(b, {largestUnit:'year'}) | P36Y4M23D |
isBefore(a, b) | Temporal.PlainDate.compare(a, b) < 0 | -1 | 0 | 1 |
isEqual(a, b) | a.equals(b) | true |
startOfDay(d) | zdt.startOfDay() | 2026-08-07T00:00:00-06:00[…] |
setDate(d, 1) | d.with({day: 1}) | 2026-08-01 |
getDay(d) | d.dayOfWeek | 5 (1 = Monday) |
getDaysInMonth(d) | d.daysInMonth | 31 |
parseISO(s) | Temporal.PlainDate.from(s) | — |
format(d, …) | d.toLocaleString('en-US', {…}) | August 7, 2026 |
isValid(d) | try { … } catch | throws RangeError |
roundToNearestHours(d) | zdt.round({smallestUnit:'hour'}) | …T16:00:00-06:00 |
| (no equivalent) | zdt.withTimeZone('Europe/Madrid') | …T23:30:00+02:00[…] |
Two notes on that table:
dayOfWeek starts at 1 and 1 is Monday, unlike Date’s getDay() where 0 is Sunday. It’s one of the few places where a careless migration introduces a silent bug.
Duration.total() needs a reference point when the duration contains months, because months aren’t all the same length:
Temporal.Duration.from('P2M10D').total({unit: 'day', relativeTo: '2026-01-01'})
// → 69
Without relativeTo, it throws. That’s inconvenient and it’s correct: the question “how many days are two months” has no answer without saying starting when.
If you need a one-off calculation without writing code, the site’s calculator does exactly this:
Migrating without breaking anything
No big bang required. Date and Temporal interoperate both ways:
// Date → Temporal
new Date('2026-08-07T18:00:00Z').toTemporalInstant()
// → 2026-08-07T18:00:00Z
// Temporal → Date
new Date(Temporal.Instant.from('2026-08-07T18:00:00Z').epochMilliseconds)
// → 2026-08-07T18:00:00.000Z
Date.prototype.toTemporalInstant() ships built in, so the bridge already exists. The strategy I’d recommend:
- Start with the domain layer, not the presentation layer. Where you compute due dates, ages, and deadlines is where
Datehurts you. - Store in the database exactly as before. An epoch
Instantor an ISO string; don’t change the schema. - Convert at the edges. Temporal inside,
Dateat the boundary with libraries that don’t support it yet. - Migrate what has tests first. Date bugs are subtle, and
dayOfWeekchanging base is the perfect example of what a test catches and a visual review doesn’t.
And if you’re working with epoch timestamps mid-migration, keep the converter handy:
In the browser
Until Safari ships it in stable, polyfill:
npm install temporal-polyfill
import { Temporal } from 'temporal-polyfill';
It isn’t small, so if your only problem is formatting dates, Intl.DateTimeFormat already solves that with no dependencies. The polyfill pays off when you do date arithmetic, which is where Date genuinely fails.
When NOT to migrate yet
- If you’re on Node 22 or 24. Temporal isn’t there; you’d need the polyfill on the server too, and the payoff shrinks.
- If you only format dates for display.
Intl.DateTimeFormatalready does that well and requires nothing. - If you depend on libraries that return
Date. You’ll be converting at every boundary; wait for them to update. - If your app lives in a single time zone with no DST. Temporal’s strongest argument doesn’t apply to you; migrate at your leisure.
Conclusion
Temporal is one of the few additions to the language that fixes a real problem instead of adding syntactic sugar. Date was never fixable: conflating “calendar date” with “instant in time” in a single mutable object is a root design error, and you don’t repair that by adding methods.
What to take away:
- In Node 26 it’s already there, no flags. In the browser, polyfill until Safari ships it.
Dateis not deprecated and isn’t going away. Migrating is a quality decision, not an emergency.- If your app crosses time zones or does deadline arithmetic, migrate the domain layer. That’s where
date.getTime() + days * 86400000has been lying to you eleven months a year.
And if you take away a single example, make it Chile’s: two days after noon is noon, not one o’clock, and only 47 hours pass in between.