The JavaScript Temporal API: a practical guide to replacing Date

JavaScript's Date object has been broken since 1995. Months start at zero. Every instance is mutable, so a function can silently change a date you passed in. Timezone handling is a minefield. Parsing is inconsistent across engines. We've all worked around it with Moment, date-fns, or Day.js — but as of Node.js 26 (May 2026) and the ECMAScript 2026 spec, we no longer have to. The Temporal API is here, built into the language, and it fixes every single one of those problems. Here's how it actually works and how to start using it today.

Why Date needed replacing

It's worth understanding why Temporal exists before learning the API, because the design decisions only make sense once you've felt the pain. The core problems with Date are:

  • Mutabilitydate.setMonth(3) changes the object in place. Pass a Date into a function and it can come back different. This creates an entire class of bugs that are hard to trace.
  • No timezone support — a Date holds a UTC timestamp internally and renders it in the host's local timezone. There is no way to say "3pm in Tokyo" — you get "3pm in whatever timezone the server happens to be in", and the offset math is yours to do by hand.
  • Months are 0-indexednew Date(2026, 6, 21) is July, not June. Every developer gets bitten by this at least once.
  • Parsing is unreliableDate.parse("2026-07-21") returns different results in different engines and different locales. The spec doesn't fully pin it down, so you can't trust it.

Libraries like Moment.js papered over these issues for years, but they added bundle weight and their own API surfaces. Temporal solves it at the language level, which means no dependency, no tree-shaking worries, and every engine agrees on behavior.

The Temporal type system — pick the right one

The single biggest shift from Date to Temporal is that there isn't one date type anymore. Temporal gives you several, and each one represents a different concept. Choosing the right type is most of the work — after that, the methods are obvious.

Temporal type system PlainDate 2026-07-21 PlainTime 14:30:00 PlainDateTime 2026-07-21T14:30 + timezone ZonedDateTime 2026-07-21T14:30+02:00 [Africa/Harare] Instant 2026-07-21T12:30:00Z .toInstant() Duration P1Y2M3DT4H No timezone info ("wall-clock") Timezone-aware (exact moment)
Temporal splits "a date" into distinct types — the right choice depends on whether you need a timezone.

Here's the mental model: if you don't need a timezone, use a "Plain" type. If you do, use ZonedDateTime. And if you need a single point on the universal timeline (a server log, a database timestamp), use Instant.

PlainDate — dates without timezone baggage

A birthday is July 21st. Not July 21st in UTC, or July 21st at some offset — just July 21st. That's what Temporal.PlainDate represents: a calendar date with no time and no timezone attached.

// Create from a string — months are 1-indexed, finally
const birthday = Temporal.PlainDate.from("2026-07-21");

// Or from fields — no more month-minus-one
const deadline = Temporal.PlainDate.from({ year: 2026, month: 12, day: 31 });

// Arithmetic returns a new object (immutable!)
const nextWeek = birthday.add({ days: 7 });
// birthday is still 2026-07-21 — nothing mutated

// Comparison is built in
Temporal.PlainDate.compare(birthday, deadline); // -1 (birthday is earlier)

That immutability isn't a small thing. With Date, I've tracked down bugs where a sorting function called setDate() and silently changed every date in the array. Temporal makes that impossible — every arithmetic operation returns a new object.

ZonedDateTime — when the timezone actually matters

If you're scheduling a meeting, sending a notification at a local time, or displaying "event starts at 2pm your time", you need a timezone. ZonedDateTime is the type that carries one:

// A meeting at 2:30pm in Harare
const meeting = Temporal.ZonedDateTime.from({
  year: 2026, month: 7, day: 21,
  hour: 14, minute: 30,
  timeZone: "Africa/Harare"
});

// What time is that in London?
const inLondon = meeting.withTimeZone("Europe/London");
console.log(inLondon.toString());
// 2026-07-21T13:30:00+01:00[Europe/London]

// DST is handled automatically — no manual offset math
const winterMeeting = Temporal.ZonedDateTime.from(
  "2026-12-21T14:30:00[America/New_York]"
);
// Temporal knows New York is UTC-5 in December, UTC-4 in summer

The critical design decision here is that ZonedDateTime stores the IANA timezone name (like America/New_York), not a fixed offset. That means it handles daylight saving time transitions correctly — something that is essentially impossible to do reliably with Date.

Instant — the universal timestamp

An Instant is a point on the timeline, in UTC, with nanosecond precision. It's what you want for server logs, database timestamps, and "when did this event actually happen" questions:

// Right now, as a precise UTC timestamp
const now = Temporal.Now.instant();

// From a string
const serverLog = Temporal.Instant.from("2026-07-21T12:30:00Z");

// Convert to a local time for display
const localTime = serverLog.toZonedDateTimeISO("Africa/Harare");
console.log(localTime.toPlainTime().toString()); // "14:30:00"

The flow is almost always: store as Instant (or its string form), display as ZonedDateTime in the user's timezone. This is the pattern every timezone tutorial tells you to follow, but Temporal is the first built-in API that makes it natural rather than painful.

Duration and arithmetic — no more manual millisecond math

Adding 30 days to a Date meant date.setDate(date.getDate() + 30) — mutating the object, and hoping the month rollover worked. Temporal makes arithmetic explicit and safe:

// Durations are their own type
const trialPeriod = Temporal.Duration.from({ days: 14 });
const subscription = Temporal.Duration.from({ months: 1 });

// Add a duration to a date
const signupDate = Temporal.PlainDate.from("2026-07-21");
const trialEnd = signupDate.add(trialPeriod);  // 2026-08-04
const renewalDate = signupDate.add(subscription); // 2026-08-21

// Difference between two dates
const daysLeft = Temporal.PlainDate.from("2026-12-31")
  .since(signupDate, { largestUnit: "day" });
console.log(daysLeft.toString()); // "P163D" — 163 days

// Duration comparison
const longer = Temporal.Duration.compare(trialPeriod, subscription);
// Works correctly even across month boundaries

The since() and until() methods replace the old "subtract two timestamps and divide by 86400000" pattern. They return a proper Duration object that you can inspect, format, and pass around — not a raw number of milliseconds that you have to decode.

Migrating from Date — the practical path

You don't have to rewrite everything at once. The cleanest migration path I've found is:

  1. New code uses Temporal. Stop writing new Date() in any new function.
  2. Boundary conversion. Where old code hands you a Date, convert at the boundary:
    // Legacy Date → Temporal Instant
    const legacy = new Date();
    const instant = legacy.toTemporalInstant();
    
    // Temporal Instant → legacy Date (for old APIs)
    const backToDate = new Date(instant.epochMilliseconds);
  3. Replace date-fns / Day.js / Moment gradually. Most of what those libraries do — add days, format, compare, parse ISO strings — Temporal handles natively. Each time you touch a file that imports a date library, try switching that function to Temporal.
  4. Drop the dependency once the import count hits zero.

In Node.js 26, Temporal is available with no flags or polyfills. For browsers, Chrome and Edge are shipping support in mid-2026 (behind a flag initially), and Firefox and Safari are actively implementing. In the meantime, the @js-temporal/polyfill package gives you the full API today — write against the real Temporal, and remove the polyfill import when native support lands.

The patterns that change

A few common Date patterns and their Temporal replacements, side by side:

  • Get today's date: new Date()Temporal.Now.plainDateISO()
  • Parse an ISO string: new Date("2026-07-21")Temporal.PlainDate.from("2026-07-21")
  • Add days: d.setDate(d.getDate() + 7)d.add({ days: 7 })
  • Compare dates: d1.getTime() > d2.getTime()Temporal.PlainDate.compare(d1, d2)
  • Days between: (d2 - d1) / 86400000d1.until(d2, { largestUnit: "day" }).days
  • Current Unix timestamp: Date.now()Temporal.Now.instant().epochMilliseconds

Every one of these is more readable, more explicit about what it returns, and impossible to accidentally mutate. That's the real win — not just nicer syntax, but fewer bugs by design.

Related
→ WebSocket vs SSE vs WebTransport: picking the right real-time channel → Send files between phones with no internet → MDN Temporal API reference
Is the Temporal API a replacement for JavaScript Date?

Yes. Temporal is designed to fully replace the legacy Date object for new code. Date stays in the language for backward compatibility, but Temporal fixes every major Date problem — mutability, timezone handling, month indexing, and unreliable parsing. Node.js 26 ships Temporal enabled by default, and it's part of the ECMAScript 2026 specification.

Can I use the Temporal API in production today?

In Node.js 26 (released May 2026), yes — Temporal is enabled by default with no flags. Browser support is rolling out: Chrome and Edge ship it behind a flag in mid-2026, with full unflagged support expected soon. For browsers that don't have it yet, the @js-temporal/polyfill package provides a complete implementation you can use today and remove later.

What is the difference between PlainDate, ZonedDateTime and Instant?

PlainDate represents a calendar date with no time or timezone — like a birthday or a deadline. ZonedDateTime represents a specific moment in a specific timezone — like a meeting at 2pm London time. Instant represents a single point on the universal timeline in UTC — like a server timestamp. Temporal forces you to choose the right type upfront, which prevents the timezone bugs that Date makes so easy.