The Temporal API in JavaScript: How to Use Dates That Finally Work (and Migrate Off Date)
For thirty years the broken corner of JavaScript has been dates. Temporal reached Stage 4 at the TC39 plenary in March 2026, it can already be used without flags and Node.js 26 ships it enabled by default: here are the concrete recipes and how to migrate without rewriting your project.
What Temporal Is and What It Actually Fixes
Temporal is the replacement for the Date object that spent nine years working through JavaScript's standardization process. It is not another library you install: it is a language API, with distinct types for things Date crammed into a single object, and with immutability across every operation.
The Five Problems With Date, One by One
- Months run from 0 to 11, so
new Date(2026, 8, 25)is September, not August: an off-by-one-month bug in half the forms on the web. - Objects are mutable: one
setMonth()mutates the object other parts of your code were holding, without anyone asking for it. - There is no "date only" type and no real time-zone-aware type. Everything is represented as an instant measured in the system zone.
- String parsing depends on the engine:
new Date('2026-09-25')is treated as UTC whilenew Date('2026/09/25')is treated as local time. - Arithmetic is done by adding milliseconds, so adding 24 hours is not the same as adding one day when that day changes the clock for daylight saving time.
Temporal fixes all five with immutable types, 1-based months, zoned and unzoned types, explicit construction and calendar-aware arithmetic.
Picking the Right Type: Instant, ZonedDateTime, PlainDate, PlainTime, PlainDateTime and Duration
Choosing the wrong type is 80% of the mistakes people make at the start. The rule is simple: if it represents a moment in the world, it needs a zone; if it represents a calendar date, it does not.
Read also
Temporal.Instant: an exact point in time with no time zone, such as the moment an audit record was written.Temporal.ZonedDateTime: an instant with an explicit time zone, the right type for calendars and reminders.Temporal.PlainDate,PlainTimeandPlainDateTime: date, time or both without a zone, for things like a deadline or opening hours.Temporal.Duration: an amount of time (days, hours, minutes) you can add, compare and express in the unit you need.
What Date Still Does (and Will Keep Doing)
Date is not going away. It is still what many browser APIs return and accept, what most third-party libraries expect and what several serialization formats use. At the system boundary you write explicit conversions, which is healthy: each side states which type it speaks.
Real Status in September 2026: Where It Works Today
One nuance that matters if you like citing the standard: in TC39's official finished-proposals table, consulted on September 25, 2026, Temporal appears with an expected publication year of 2027, meaning the ES2027 edition, alongside other proposals that also reached Stage 4 this year. In practice that date barely matters, because implementations run ahead of the paperwork: the API has been available in engines for months.
Browsers: Chrome and Firefox Ship It; Safari Still Doesn't
According to the compatibility data consulted on September 25, 2026, Chrome supports it from version 144, Firefox from 139 and Edge mirrors Chrome, while Safari only has it in Technology Preview, not in stable releases or on iOS. Caniuse puts global usage at 71.34%: enough to use it, not enough to skip the polyfill if your audience includes Safari.
Node.js 26 Enables It by Default and Reaches LTS in October
The official Node.js 26.0.0 release note, published on May 5, 2026, lists "the Temporal API enabled by default" among the highlights, alongside V8 14.6 and Undici 8.0. That version enters long-term support in October 2026. If your backend already runs Node 26 there is nothing to enable: Temporal is simply there. Deno has shipped it since 2.7.
The Official Polyfill and When It Is Worth Loading
The polyfill is called @js-temporal/polyfill and, unlike others, it does not install a global: it exports its own Temporal plus a toTemporalInstant helper.
npm install @js-temporal/polyfill
import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
Date.prototype.toTemporalInstant = toTemporalInstant;That avoids clobbering the native implementation where one exists, but it forces a decision: if you load the polyfill in a runtime that already has Temporal, you end up with two implementations side by side, and objects from one do not behave like objects from the other. Load it only when needed, for instance by checking whether the global exists, and keep a single source of Temporal across the codebase.
Recipes You Use Every Day
Today's Date in the User's Time Zone
const today = Temporal.Now.plainDateISO(); // 2026-09-25, date only
const now = Temporal.Now.zonedDateTimeISO(); // time with the system zone
const inMadrid = Temporal.Now.zonedDateTimeISO('Europe/Madrid');Notice the difference: if what you want is "today's date for the user", plainDateISO() is the right call; if you want to know what time it is somewhere specific, you need a ZonedDateTime.
Adding Days, Months and Years Without Weird Results
const due = Temporal.PlainDate.from('2026-09-25').add({ days: 30 }); // 2026-10-25
const firstOfMonth = due.with({ day: 1 }); // 2026-10-01Operations return a new object: the original never changes. with() replaces a single field and takes the place of the old setDate, without side effects on other parts of the code.
How Many Days Are Left: Durations and Date Differences
const start = Temporal.PlainDate.from('2026-09-25');
const end = Temporal.PlainDate.from('2026-12-31');
start.until(end, { largestUnit: 'days' }).days; // 97
start.until(end, { largestUnit: 'weeks' }).toString(); // P13W6Duntil() returns a Duration, not a number: you can read .days, ask for .total({ unit: 'days' }) to get a decimal value, or keep the weeks-and-days representation. For a precise day count, passing largestUnit explicitly saves you surprises.
Daylight Saving Time: the Arithmetic Date Got Wrong
const booking = Temporal.ZonedDateTime.from('2026-09-25T09:00[Europe/Madrid]');
booking.add({ days: 1 }).hour; // 9: keeps the clock time
booking.add({ hours: 24 }).hour; // 24 real hours of elapsed timeIn a zone with daylight saving time, if the clock changes between those two dates, the two results differ by an hour. Verified by running the example in Europe/Madrid across the October 2026 clock change: adding a day keeps 09:00 while adding 24 hours lands on 08:00. Adding days preserves the local time, which is what a calendar expects; adding hours measures elapsed time. That is the classic Date millisecond bug, solved by the type system.
Converting Between Time Zones Without Guessing
const meeting = Temporal.ZonedDateTime.from('2026-09-25T15:00[Europe/Madrid]');
const inBogota = meeting.withTimeZone('America/Bogota');
inBogota.hour; // 8
inBogota.toString(); // 2026-09-25T08:00:00-05:00[America/Bogota]The conversion does not touch the instant, it changes the zone used to display it. And toString() includes the offset and the zone name, so what you store in an API is unambiguous.
Talking to the Date You Already Have: Milliseconds and toTemporalInstant()
const legacy = new Date();
const instant = legacy.toTemporalInstant(); // Date -> Instant
const back = new Date(instant.epochMilliseconds); // Instant -> Date
const fromMs = Temporal.Instant.fromEpochMilliseconds(1758800000000);toTemporalInstant() exists natively where the engine ships Temporal, and the polyfill adds it to Date.prototype when you assign it. It is the most useful entry point for a safe migration: the boundary keeps speaking Date and everything inside speaks Temporal.
Displaying Dates in the User's Language With Intl
const date = Temporal.PlainDate.from('2026-09-25');
date.toLocaleString('en-GB', { dateStyle: 'long' });
new Intl.DateTimeFormat('es-ES', { dateStyle: 'full' }).format(date);Temporal computes and Intl presents: formatting stays exactly what you were already using. Separating those two responsibilities is half the battle in any app with more than one language.
Migrating From date-fns or dayjs Without a Rewrite
Equivalents for the Most Common Calls
addDays(date, 7)becomesdate.add({ days: 7 }).differenceInDays(a, b)becomesb.until(a, { largestUnit: 'days' }).days.startOfMonth(date)becomesdate.with({ day: 1 }).isAfter(a, b)becomesTemporal.PlainDate.compare(a, b) > 0.format(date, 'yyyy-MM-dd')becomesdate.toString(), andtoLocaleString()for display.
Strategy: Call Site by Call Site, Behind Your Own Utility Layer
The expensive mistake is swapping the whole library at once. The safe route is different: create your own utility layer (say lib/dates.js) that today wraps the library and tomorrow returns Temporal objects, then migrate call site by call site, starting where the pain actually is: time-zone math, date differences and anything that depends on daylight saving time. The rest of the codebase never notices, because it still calls your layer.
What You Should Leave as Date for Now
Browser API inputs and outputs that expect Date, the serialization you already have, and any third-party library whose signature takes Date: wrap those, do not rewrite them. The benefit of dropping a dependency is not a magic kilobyte number, it is no longer maintaining and updating code you do not need.
Common Mistakes When You Start
Comparing With === and Sorting With Less-Than: They Don't Behave as You Expect
a === b compares references, so two objects holding the same date return false. For value equality there is a.equals(b). With ordering operators it gets worse: the engine coerces the object to a string, and those comparisons turn fragile as soon as different time zones are involved. Use the comparison methods that come with the type.
Mistaking a PlainDateTime for a Real Instant
A PlainDateTime has no idea which zone it belongs to: it is "September 25 at 09:00" somewhere in the world. To record when something happened, use Instant or ZonedDateTime. Storing a PlainDateTime as if it were a fixed moment is the fastest way to lose an hour in production.
Duplicating the Global Temporal or Loading the Polyfill Everywhere
Pick one loading condition and stick to it. If the polyfill activates where native Temporal already exists, you get two implementations, plus comparisons between objects from each, with confusing results. In TypeScript, also check the type library your version ships with and, if it does not include Temporal yet, declare the minimum you need in the meantime.
Conclusion
Temporal is no longer a promise: it is in the standard, enabled by default in Node 26 and shipping in Chrome, Edge and Firefox, with Safari pending and a polyfill to cover it. The sensible decision is not to migrate everything today, it is to start where Date is costing you money. To keep going, see also how to add interactivity without writing JavaScript using HTMX and, if you are just starting out, how to use localStorage and sessionStorage or what JavaScript is and what it is used for.
References: Temporal on MDN, compatibility on caniuse, the Node.js 26.0.0 release note and TC39's finished proposals.


