---
title: 'The Temporal API: Fixing 30 Years of JavaScript Dates'
description: "JavaScript's Date has shipped the same bugs since 1995 — mutation, zero-based months, no real time zones. Temporal fixes them, run every example live."
date: 2026-07-01
tags: [javascript, temporal, es2026, dates, web-platform]
---

`Date` is the last unfixed corner of early JavaScript. It was ported from Java's `java.util.Date` in ten days in 1995 — and Java deprecated most of that class the very next year. We kept it. Three decades later, it still mutates in place, counts January as month `0`, has no concept of a time zone beyond "the user's machine," and forces you to reach for Moment, date-fns, or Luxon the moment you do anything real.

`Temporal` is the standard library that replaces it. It's a top-level namespace (`Temporal.PlainDate`, `Temporal.ZonedDateTime`, `Temporal.Duration`, …), it's immutable, it's time-zone-aware by design, and it landed in the ECMAScript spec for ES2026. Every widget below runs live in your browser — natively where your engine already ships Temporal, and through the official polyfill otherwise.

<div data-island="WorldClock">
<pre><code>Temporal.Now.zonedDateTimeISO('Europe/Kyiv').toString();</code></pre>
<p><em>Expected:</em> a live clock like <code>2026-07-01T18:30:12+03:00[Europe/Kyiv]</code> across four cities, ticking each second.</p>
</div>

Those clocks are four `Temporal.Now.zonedDateTimeISO(zone)` calls on a one-second interval. No `Date`, no manual offset math, no library. Let's go through what changed — and try each one.

## 1. Objects are immutable

`Date`'s mutators (`setMonth`, `setDate`, `setHours`…) change the object in place. Assign a `Date` to a second variable and you have two names for one object — mutate through either and both "change." This is the source of a whole category of "why did my start date move?" bugs.

Temporal values are immutable. Every operation — `add`, `subtract`, `with`, `round` — returns a **new** value and leaves the original alone.

<div data-island="ImmutabilityDemo">
<pre><code>const t1 = Temporal.PlainDate.from('2026-01-15');
const t2 = t1.add({ months: 1 });</code></pre>
<p><em>Expected:</em> <code>t1</code> stays <code>2026-01-15</code>; <code>t2</code> is a fresh <code>2026-02-15</code>. The legacy <code>Date</code> equivalent mutates both aliases.</p>
</div>

## 2. Months start at 1

`new Date(2026, 1, 15)` is not February 15th's sibling — it's **March**, because `Date` counts months from `0`. Worse, out-of-range values don't error; they overflow into adjacent months and years silently. Type `12` below and watch the legacy column roll into next year.

Temporal counts months from `1`, matching ISO 8601 and human intuition, and throws a `RangeError` on invalid input instead of guessing.

<div data-island="MonthDemo">
<pre><code>Temporal.PlainDate.from(
  { year: 2026, month: 13, day: 15 },
  { overflow: 'reject' },
);</code></pre>
<p><em>Expected:</em> a <code>RangeError</code> — with <code>overflow: 'reject'</code> month <code>13</code> is rejected, not silently rolled into 2027 the way <code>new Date(2026, 13, 15)</code> is.</p>
</div>

## 3. Calendar math that respects the calendar

Ask "how old is someone born on 1990-02-15?" with `Date` and you'll subtract two epoch millisecond values, divide by `1000 * 60 * 60 * 24 * 365.25`, and quietly get the wrong answer around leap years and month boundaries.

Temporal does calendar arithmetic directly. `end.since(start, { largestUnit: 'years' })` returns a `Duration` broken into years, months, and days — leap years and variable month lengths handled for you. `Duration` also rounds (`round()`) and collapses to a single unit (`total()`).

<div data-island="DurationDemo">
<pre><code>Temporal.PlainDate.from('2026-06-30')
  .since('1990-02-15', { largestUnit: 'years' });</code></pre>
<p><em>Expected:</em> <code>36 years, 4 months, 13 days</code> — leap years and variable month lengths handled automatically.</p>
</div>

## 4. Time zones are first-class

`Date` stores one instant in UTC and renders it in the machine's local zone. Want a specific zone? Your only tool is `toLocaleString(..., { timeZone })`, which hands back a **string** — dead text you can't do further math on.

`Temporal.ZonedDateTime` is a real object that carries its zone. `PlainDateTime.toZonedDateTime(zone)` pins a wall-clock time to a zone; `withTimeZone(other)` re-projects the same instant elsewhere. Here's the everyday version: pick a meeting time in one city, see it in five.

<div data-island="SchedulerDemo">
<pre><code>const kyiv = Temporal.PlainDateTime.from('2026-07-01T15:30')
  .toZonedDateTime('Europe/Kyiv');
kyiv.withTimeZone('Asia/Tokyo').toString();</code></pre>
<p><em>Expected:</em> <code>15:30</code> in Kyiv is <code>21:30</code> in Tokyo — the same instant, projected into five cities at once.</p>
</div>

## 5. Day-of-week without the off-by-one

`Date.getDay()` returns `0` for Sunday, so every "skip the weekend" loop starts with a mental translation. `Temporal.PlainDate` exposes `dayOfWeek` as ISO `1`–`7` (Monday–Sunday), which makes "business days" trivial: add a day, keep it if `dayOfWeek < 6`.

<div data-island="WorkdaysDemo">
<pre><code>let date = Temporal.PlainDate.from('2026-07-01');
let added = 0;
while (added &lt; 10) {
  date = date.add({ days: 1 });
  if (date.dayOfWeek &lt; 6) added++; // Mon–Fri
}</code></pre>
<p><em>Expected:</em> 10 business days after <code>2026-07-01</code> lands on <code>2026-07-15</code>, with the four weekend days skipped.</p>
</div>

## 6. Relative time from a clean instant

"5 minutes ago," "in 3 hours" — feed strings for these and you'll fight DST and zone drift. Compute the gap on `Temporal.Instant` (a fixed point on the timeline, zone-free) and the number is exact. Pair it with the native `Intl.RelativeTimeFormat` for the wording.

<div data-island="RelativeTimeDemo">
<pre><code>const then = Temporal.PlainDateTime.from('2026-07-01T14:00')
  .toZonedDateTime('Europe/Kyiv').toInstant();
Temporal.Now.instant().since(then).total({ unit: 'seconds' });</code></pre>
<p><em>Expected:</em> an exact second count that <code>Intl.RelativeTimeFormat</code> renders as, e.g., <code>"90 minutes ago"</code> — no DST skew.</p>
</div>

## 7. Adding a day is not adding 24 hours

This is the bug that ships to production. On a DST transition, a calendar day and 24 absolute hours are **different amounts of time** — one of them lands an hour off. `Date` can't even express the distinction. Temporal separates `add({ days: 1 })` (calendar) from `add({ hours: 24 })` (absolute) and adjusts the UTC offset for you.

<div data-island="DstDemo">
<pre><code>const t = Temporal.ZonedDateTime.from('2026-03-08T01:30:00[America/New_York]');
t.add({ hours: 24 }); // absolute
t.add({ days: 1 });   // calendar</code></pre>
<p><em>Expected:</em> across the spring-forward jump the two results differ by an hour, and the UTC offset shifts from <code>-05:00</code> to <code>-04:00</code>.</p>
</div>

## 8. Sorting without a detour

Sorting `Date`s means mapping to `getTime()` inside a comparator. Temporal ships a static comparator you pass straight to `sort`:

<div data-island="SortDemo">
<pre><code>dates.sort(Temporal.PlainDate.compare);</code></pre>
<p><em>Expected:</em> the dates come back in ascending calendar order — no <code>(a, b) =&gt; a.getTime() - b.getTime()</code> detour.</p>
</div>

## Shipping it today

Two lines to be native-first with a safe fallback:

```ts
import { Temporal as Polyfill } from '@js-temporal/polyfill';

export const Temporal = globalThis.Temporal ?? Polyfill;
```

Engines that already ship `Temporal` use it and skip the polyfill's ~40 KB gzip entirely; everyone else gets a spec-accurate implementation. If bundle size matters on the critical path, load the polyfill with a dynamic `import()` behind the same feature check so it only downloads where it's missing — which is exactly what the demos on this page do. Track native availability on [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal) and read the full API in the [TC39 documentation](https://tc39.es/proposal-temporal/docs/).

## Takeaways

- **Immutable by default** — no more aliased dates mutating under you.
- **1-based months, strict parsing** — invalid input throws instead of overflowing.
- **Real time zones** — `ZonedDateTime` is an object, not a formatted string.
- **Calendar-correct math** — `since`/`until`/`round`/`total` handle leap years and DST.
- **Native-first, polyfill-backed** — one line to adopt it now, zero cost once your engine ships it.

`Date` isn't going anywhere — it's load-bearing across the web. But for new code, there's no longer a reason to reach for it, and no longer a reason to pull in a date library for the basics.
