JavaScript Temporal API — 4 Patterns to Retire Your Date Hacks

2026-05-11 · Nico Brandt

You shipped this bug last year and never noticed: new Date(2026, 0, 31).setMonth(1) returns March 3rd. Not February 31. Not an error. March. Because Date silently overflows months, and you’d have to read the spec to know.

The JavaScript Temporal API hit Stage 4 on March 11, 2026. It ships in Chrome 144 and Firefox 139 — not as another library, but built into the runtime. And it doesn’t just replace Date; it retires every workaround you’ve written around Date over the last decade.

Four patterns, each one ending a specific Date bug. A decision tree so you stop guessing which Temporal type to reach for. A polyfill snippet. And an honest answer to the question you’ll ask next: should I migrate today? (Safari hasn’t shipped. We’ll get to that.)

Why Date Has To Go (And Why the JavaScript Temporal API Is Different)

The JavaScript Temporal API is the built-in replacement for the Date object, shipping in Chrome 144+ and Firefox 139+. To replace the JavaScript Date object with something that doesn’t lie about months, timezones, or parsing, you use immutable, timezone-aware types that fix Date’s 0-indexed months, mutability bugs, and timezone ignorance.

Date has four original sins. You’ve been working around all of them. Months count from zero — December is 11. Every setter mutates in place. The object only knows “UTC” and “local” (whatever the runtime decided that means). And Date.parse() is implementation-defined for anything beyond strict ISO 8601. None of these are fixable without breaking the web. So TC39 didn’t try.

Temporal isn’t a class — it’s a namespace exposing several types: Temporal.PlainDate, Temporal.PlainDateTime, Temporal.ZonedDateTime, Temporal.Instant, Temporal.Duration. Months are 1-based. Everything is immutable; operations return new objects.

Stage 4 on 2026-03-11. Firefox 139 shipping it, Chrome 144 shipping it, Edge along for the ride.

Safari Technology Preview has it behind a runtime flag — full Safari is expected later this year.

Four patterns, four bugs, four Temporal types. Let’s start with the one you wrote this morning and didn’t notice.

Pattern 1: Stop Mutating Dates — Use Temporal.Now and PlainDate

Here’s the bug, dressed up to look like working code:

function getNextMonth(date) {
  date.setMonth(date.getMonth() + 1);
  return date;
}

const today = new Date();
const next = getNextMonth(today);
// today is now the same as next. Whoops.

setMonth mutates the original. Pass a Date into a function, and the caller’s reference now points to whatever the callee did to it. Every senior dev has chased this exact bug — usually at 11 PM, usually inside a React state update.

The Temporal version cannot do this:

const today = Temporal.Now.plainDateISO();   // 2026-05-11
const next = today.add({ months: 1 });       // 2026-06-11
// today is still 2026-05-11. Always.

Temporal.Now.plainDateISO() gives you today as a PlainDate. .add() returns a new PlainDate. The original is frozen. Use it as a Map key, store it in React state, pass it across async boundaries — defensive cloning is gone.

While you’re here, notice the months: Temporal.PlainDate.from({ year: 2026, month: 1, day: 31 }) is January 31, not February 31.

Months count from 1. The decade-long footgun of new Date(2026, 0, 31) meaning January is over.

Immutability changes how date code reads. No defensive new Date(other.getTime()) at the top of every function. No “did this get mutated three call frames up?”

Values flow through transformations, like every other modern data type you use. But immutability only fixes one of Date’s sins — the timezone problem is next.

Pattern 2: Stop Faking Timezones — Use ZonedDateTime

So dates don’t mutate anymore. But Date has another problem: it only knows two timezones. UTC, and whatever the runtime calls “local.” That’s it. Scheduling a 9 AM meeting in Tokyo from a server running in Berlin? You’re stringing together UTC offsets, hoping DST doesn’t move underneath you, and writing defensive comments like // trust me on this one.

Temporal.ZonedDateTime carries an IANA timezone as part of the value:

const meeting = Temporal.ZonedDateTime.from({
  year: 2026, month: 5, day: 11,
  hour: 9, minute: 0,
  timeZone: 'Asia/Tokyo'
});

const inBerlin = meeting.withTimeZone('Europe/Berlin');
// 02:00 local in Berlin — calculated correctly.

Here’s the gotcha Date can’t see. In America/New_York, on March 8, 2026, clocks jump from 02:00 to 03:00. The local time 02:30 doesn’t exist:

// Date silently picks something
new Date('2026-03-08T02:30:00-05:00');

// ZonedDateTime makes you decide
Temporal.ZonedDateTime.from(
  { year: 2026, month: 3, day: 8, hour: 2, minute: 30, timeZone: 'America/New_York' },
  { disambiguation: 'reject' }  // throws on impossible local time
);

disambiguation accepts 'compatible', 'earlier', 'later', or 'reject'. You choose what an ambiguous time means. With Date, the runtime chose for you — silently. You found out when a user complained their alarm rang twice.

Recurring meetings stay anchored. meeting.add({ hours: 24 }) lands at the same wall-clock time the next day in the target timezone, even across DST. With Date, you’d drift by an hour twice a year — and your calendar app would slowly become a bug report.

When you don’t need a timezone at all — a log timestamp, “when did this happen” — reach for Temporal.Instant. It’s the absolute moment, unbothered by where on earth it occurred.

Pattern 3: Stop Doing Date Math By Hand — Use Duration

Timezones handled. Now the math problem: how do you calculate “months until the deadline” with Date?

const months = Math.floor((deadline - today) / (1000 * 60 * 60 * 24 * 30));

That’s wrong. Months aren’t 30 days. Years aren’t 365. February exists. Your code shipped, and somewhere a contract auto-renewed three days early.

Temporal.Duration is calendar-aware:

const today = Temporal.Now.plainDateISO();
const deadline = Temporal.PlainDate.from('2026-12-15');

const remaining = today.until(deadline, { largestUnit: 'months' });
// Duration { months: 7, days: 4 }

.until() looks forward, .since() looks back. Both return a Temporal.Duration with real years, months, weeks, days, hours — the calendar units a human actually thinks in. An invoice due “45 days from today” is today.add({ days: 45 }). An age from a birth date is birthday.until(today, { largestUnit: 'years' }). No magic numbers, no off-by-one shame.

Formatting comes free:

remaining.toLocaleString('en-US', { style: 'long' });
// "7 months, 4 days"

No date-fns, no Moment, no separate i18n dependency. Intl is in the box.

For the billing case — round to the nearest half-hour, half-up — there’s .round({ largestUnit, smallestUnit, roundingMode }). The arguments mean what they say, which is rare enough in JavaScript to be worth pointing out.

You’ve stopped mutating, stopped faking timezones, stopped doing date math by hand. There’s one more place Date quietly lies to you, and it’s where every API surface meets the outside world.

Pattern 4: Stop Trusting Date.parse — Use Temporal.Instant.from

You’ve fixed mutation, timezones, and arithmetic. One last lie: what Date tells you when it parses a string.

Date.parse('2026-05-11') returns midnight UTC on V8. It might return midnight local on a different engine. Date.parse('2026/05/11') is implementation-defined — the spec literally says implementations may fall back to whatever format they deem appropriate. That’s not parsing. That’s a coin flip with a stack trace.

Temporal.Instant.from() accepts only strict ISO 8601 with an explicit offset or 'Z':

Temporal.Instant.from('2026-05-11T14:30:00Z');         // OK
Temporal.Instant.from('2026-05-11T14:30:00+05:30');    // OK
Temporal.Instant.from('2026-05-11');                   // RangeError
Temporal.Instant.from('2026/05/11');                   // RangeError

A noisy throw beats a silent wrong answer every time you’re parsing data you didn’t write.

The receiving end gets clearer too. If your input is '2026-05-11', that’s a date — parse as PlainDate. If it’s '2026-05-11T09:00:00[Asia/Tokyo]', that’s a wall time in a place — parse as ZonedDateTime. If it’s '2026-05-11T14:30:00Z', that’s an absolute moment — parse as Instant. The type you choose tells you what the string actually meant.

Every Temporal type round-trips through .toString() and .from(). Dump them into JSON, parse them on the other side, end up with the same value — no custom revivers, no hidden timezone re-interpretation.

Four patterns, four bugs. Which type do you reach for first?

Which Temporal Type Should You Use? A Decision Tree

This is the question every adopter asks, and no Temporal API tutorial in 2026 answers cleanly. Use this:

The meta-rule: pick the most specific type your data actually has. Don’t promote a PlainDate to a ZonedDateTime by guessing the timezone — that’s how “May 11” becomes “May 10, 23:00 in the wrong zone.” If you don’t know the timezone, you don’t have a ZonedDateTime yet. That’s information, not a problem.

Temporal.Duration is the odd one out: it’s a length, not a point. The gap between two points, the answer to “how long until X.” Reach for it when you have two Temporal values and want what’s between them.

Polyfill Strategy (And When You Can Drop It)

Feature-detect, don’t user-agent sniff:

if (typeof Temporal === 'undefined') {
  const mod = await import('@js-temporal/polyfill');
  globalThis.Temporal = mod.Temporal;
}

@js-temporal/polyfill is ~44.1 KB gzipped — smaller than Moment (75.4 KB) and a fraction of moment-timezone (114.2 KB). If you were carrying either, you’re already winning on bytes. If you’re carrying another date library, run a bundle analyzer to see the swap in real numbers — you’re likely already winning on bytes.

The honest caveat: the polyfill is still in alpha. Test it against your timezone-sensitive paths — rare IANA zones, dates before 1970, anything involving disambiguation — before you ship it. Bug reports are still landing.

Node.js doesn’t have Temporal natively yet. Load the polyfill on the server too, or wait for the Node release that picks it up.

When can you drop the polyfill? When your analytics show browsers without native Temporal are below your minimum-supported threshold. Recheck quarterly. Once Safari ships and a few releases roll forward, the polyfill becomes dead weight.

Should You Migrate Today? An Honest Readiness Check

The case for migrating now: Chrome 144, Firefox 139, and Edge 144 already cover 70–80% of the web depending on your audience. The polyfill handles the rest with one async import that costs less than the library you’re probably replacing. Use a code splitting pattern to load the polyfill only on browsers that need it — the async import stays small and doesn’t block page load.

The case for waiting: Safari hasn’t shipped. If your traffic skews iOS or macOS Safari, you’re polyfilling most of your users. You might as well stay on Date or a stable library for one more quarter. There’s no prize for migrating first.

A short checklist before you flip the switch:

Even if you don’t migrate the whole app today, new code is the right place to start. The JavaScript Temporal API and Date coexist in the same codebase without fighting — isolate the migration to one feature, one route, one component. The same way you’d introduce TypeScript without hating it or adopt a new bundler without rewriting, incremental beats heroic.

That setMonth bug from the opening? Gone — there’s no setter to call. The four patterns above are your ES2026 Temporal migration guide: replace every Date hack you’ve been carrying with immutable, timezone-aware, parse-safe code. Chrome shipped it. Firefox shipped it. Safari is next. The only question left is when, not if.