August 7, 2026

The Temporal API Ships Enabled in Node 26: Stop Fighting With Date

Photo of Marco Orta Marco Orta | 15 mins read
Compartir
A cracked brass pocket watch shedding gears on the left, with a stream of particles flowing toward concentric rings of blue light on the right
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
    StandardStage 4 since March 2026. Part of ES2026
    Node26: enabled by default. Enters LTS in October
    Chrome / EdgeSupported since 144 (January 2026)
    FirefoxSupported since 139
    SafariTechnology Preview only, behind a flag
    BaselineNo, and Safari is the blocker

    Two nuances that get reported wrong:

    • Date is not deprecated. MDN describes Temporal as “a full replacement” for Date, which isn’t the same thing. Date remains 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 representClass
    A calendar date, no timePlainDate
    A wall-clock time, no datePlainTime
    Date and time, no zonePlainDateTime
    Date and time in a specific placeZonedDateTime
    An exact point on the timelineInstant
    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 timeNow

    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 writeWith TemporalReal 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'}).days13294
    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.dayOfWeek5 (1 = Monday)
    getDaysInMonth(d)d.daysInMonth31
    parseISO(s)Temporal.PlainDate.from(s)
    format(d, …)d.toLocaleString('en-US', {…})August 7, 2026
    isValid(d)try { … } catchthrows 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:

    1. Start with the domain layer, not the presentation layer. Where you compute due dates, ages, and deadlines is where Date hurts you.
    2. Store in the database exactly as before. An epoch Instant or an ISO string; don’t change the schema.
    3. Convert at the edges. Temporal inside, Date at the boundary with libraries that don’t support it yet.
    4. Migrate what has tests first. Date bugs are subtle, and dayOfWeek changing 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.DateTimeFormat already 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.
    • Date is 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 * 86400000 has 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.

    Compartir

    Search

    Tags

    Tutorial PHP Laravel AI JavaScript Web Development Best Practices Laravel 13 Security Migration Tools Claude SEO Regular Expressions Text Manipulation