Getting Started with the Temporal API

This guide shows how to adopt the Temporal API to replace the legacy JavaScript Date object with DST-safe, immutable, timezone-aware date logic. Part of Modern Date Logic with the Temporal API.

What Breaks with Legacy Date

The legacy Date object stores a single UTC millisecond count and then applies the host machine's local timezone to every property you read. That implicit coupling produces three recurring failures. A server in UTC and a browser in America/Los_Angeles disagree on which calendar day a timestamp belongs to, so a "due today" badge renders on different days. DST transitions silently break naive arithmetic — adding 24 hours across spring-forward lands an hour off the expected wall-clock time. And because setHours(), setDate(), and friends mutate in place, a shared Date reference passed through async code causes hydration mismatches and stale React state.

Temporal fixes the architecture rather than papering over it: every type is immutable, timezone context is explicit, and you choose a type that encodes what kind of time you mean before you write any arithmetic.

Picking the Right Type

The single most important decision in Temporal is choosing the correct type up front. Choosing wrong is a latent bug, not a style preference. The decision reduces to two questions: do you need a timezone, and do you need a clock time?

Here is the decision tree the rest of this guide builds on.

Temporal type decision treeStart at the question of whether the value needs a timezone. If it needs an absolute instant, use Temporal.Instant. If it needs a wall-clock time in a real zone, use ZonedDateTime. If no timezone is needed, choose PlainDate, PlainTime, or PlainDateTime depending on whether you need date, time, or both.Need a timezoneor absolute moment?yesno (civil time)Absolute point only,no human fields?yesnoInstantstorage, logsZonedDateTimescheduling, displayNeed date, time,or both?PlainDatebirthdaysPlainTime /PlainDateTimeAdd wall-clock days with .add({ days }); add absolute time with .add({ hours }).Only ZonedDateTime knows DST — Plain types never shift offsets.

Type and Method Reference

Type / method Role Timezone caveat
Temporal.Instant Absolute point on the timeline (nanoseconds since epoch) No zone, no calendar — .toString() is always UTC Z
Temporal.ZonedDateTime Instant + IANA zone + calendar DST-aware; the only type where .add({hours}) and .add({days}) differ
Temporal.PlainDate Civil date, no time or zone Never shifts across DST; format with timeZone: 'UTC'
Temporal.PlainTime Wall-clock time, no date or zone Recurring schedule times, business hours
Temporal.PlainDateTime Date + time, no zone Form input before the zone is known
Temporal.Now.instant() Current absolute moment Independent of host zone
Temporal.Now.zonedDateTimeISO(tz) Current moment in an explicit zone Pass an IANA id; defaults to host zone if omitted
instant.toZonedDateTimeISO(tz) Attach a zone to an absolute moment Resolves to the correct offset at that instant
zdt.toInstant() Drop the zone, keep the absolute moment Lossless round-trip back to UTC

Approach A: The Legacy Date Solution

Before Temporal, the only built-in option was Date. It works for trivial cases but leaks the host zone everywhere.

// Legacy: "add one day" by adding 24 hours of milliseconds.
const start = new Date('2024-03-09T12:00:00-05:00'); // EST, day before US spring-forward
const next = new Date(start.getTime() + 24 * 60 * 60 * 1000);

// On a US/Eastern host this prints 13:00, not 12:00 — DST swallowed an hour.
console.log(next.toString());

The limitation is structural: Date has no concept of "one calendar day." It only knows milliseconds, so it cannot keep wall-clock time stable across a DST boundary. There is no API surface to express intent.

Approach B: The Temporal Solution

Temporal lets you say exactly what you mean. Adding a calendar day preserves wall-clock time; adding hours preserves absolute elapsed time.

import { Temporal } from '@js-temporal/polyfill';

const zdt = Temporal.ZonedDateTime.from(
  '2024-03-09T12:00:00[America/New_York]'
);

// .add({ days: 1 }) keeps the wall-clock 12:00 even though the offset changes.
const sameClock = zdt.add({ days: 1 });
console.log(sameClock.toString()); // 2024-03-10T12:00:00-04:00[America/New_York]

// .add({ hours: 24 }) adds real elapsed time, so the clock reads 13:00.
const sameElapsed = zdt.add({ hours: 24 });
console.log(sameElapsed.toString()); // 2024-03-10T13:00:00-04:00[America/New_York]

When you construct a ZonedDateTime from a local string that is ambiguous (fall-back) or nonexistent (spring-forward), pass an explicit disambiguation:

import { Temporal } from '@js-temporal/polyfill';

// 02:30 on this date does not exist — clocks jump 02:00 -> 03:00.
const gap = Temporal.ZonedDateTime.from(
  '2024-03-10T02:30:00[America/New_York]',
  { disambiguation: 'reject' } // throw instead of silently shifting forward
);

The disambiguation options are: 'compatible' (default, matches legacy Date and shifts forward into the gap / picks the earlier instant on overlap), 'earlier' and 'later' (explicit pick of the two candidate instants), and 'reject' (throw RangeError on any ambiguous or nonexistent time). For overflow when constructing dates, the parallel option is overflow: 'constrain' (clamp, the default) versus overflow: 'reject' (throw on Feb 30 and similar).

The deeper patterns for DST-correct arithmetic live in Working with ZonedDateTime Objects, and the pure-function arithmetic patterns in Date Arithmetic Without Mutations.

Environment Setup

Temporal reached TC39 Stage 4 (it is part of ES2026) and ships natively in recent browsers (Chrome 144+, Firefox 139+), but Safari and older runtimes still need a polyfill. Install @js-temporal/polyfill and pin it:

npm install @js-temporal/polyfill --save-exact

Import it directly in date-critical modules rather than relying on a global that may not be set yet:

import { Temporal } from '@js-temporal/polyfill';

// Optional: expose globally for modules that read globalThis.Temporal.
if (typeof globalThis.Temporal === 'undefined') {
  (globalThis as any).Temporal = Temporal; // assign yourself — the polyfill does NOT
}

For a bundler-specific walkthrough, see Install and configure Temporal polyfill in Vite. For shrinking the payload, see Temporal polyfill bundle size and tree-shaking.

Production Implementation: A Safe Parse-and-Convert Utility

Most real bugs occur at the boundary where untrusted strings enter your system. This utility validates input, picks the right type, and converts to an absolute Instant for storage — the one representation that never drifts.

import { Temporal } from '@js-temporal/polyfill';

interface ParseResult {
  instant: Temporal.Instant;
  zoned: Temporal.ZonedDateTime;
}

/**
 * Parse a wall-clock string in an explicit zone, rejecting ambiguous times,
 * and return both the absolute instant (for storage) and the zoned value.
 */
export function parseWallClock(
  isoLocal: string,
  timeZone: string
): ParseResult {
  // PlainDateTime carries no zone, so parsing cannot silently apply the host zone.
  const plain = Temporal.PlainDateTime.from(isoLocal, { overflow: 'reject' });

  // Attach the zone explicitly; 'reject' surfaces spring-forward gaps as errors.
  const zoned = plain.toZonedDateTime(timeZone, { disambiguation: 'reject' });

  return { instant: zoned.toInstant(), zoned };
}

// SSR / serverless note: store instant.toString() (UTC). Never store the host
// zone or a numeric offset. Re-attach the user's IANA zone on read.

On the server, always normalize to Instant (UTC) and store that string. Never persist a numeric offset like -05:00 — offsets change with DST and with political zone changes, so they cannot reconstruct the original zone. Store the IANA identifier alongside the instant when you need to reproduce the user's wall clock.

Edge Cases

Spring-forward gap

On the US spring-forward date, 02:30 local time does not exist. With disambiguation: 'compatible' Temporal shifts forward to 03:30; with 'reject' it throws. Decide per use case — a calendar invite should usually 'reject' and re-prompt the user.

Fall-back overlap

On fall-back, 01:30 occurs twice. 'earlier' picks the first (still DST), 'later' picks the second (standard time). Logging systems want 'earlier'; "the second time the bartender calls last orders" wants 'later'.

Civil dates have no DST

A PlainDate like a birthday must never be run through zone math. Converting it to a Date for formatting without timeZone: 'UTC' is the classic off-by-one bug — see the calendar-grid patterns in How to use Temporal.PlainDate for calendar apps.

Internationalization and Formatting

Temporal objects format through Intl for locale-aware output. Intl.DateTimeFormat construction is expensive, so cache the formatter and reuse it. Always pass an explicit timeZone so server rendering does not drift to the host zone.

import { Temporal } from '@js-temporal/polyfill';

// Cache the formatter — constructing it per render is a measurable hot path.
const berlinFormatter = new Intl.DateTimeFormat('de-DE', {
  timeZone: 'Europe/Berlin', // explicit — never inherit the host zone on a server
  dateStyle: 'full',
  timeStyle: 'short',
  hourCycle: 'h23',
});

const zoned = Temporal.Now.zonedDateTimeISO('Europe/Berlin');
// epochMilliseconds is a bigint; Number() is safe for dates within ±285,000 years.
console.log(berlinFormatter.format(new Date(Number(zoned.epochMilliseconds))));

For non-Gregorian output, pass a calendar and see Calendar Systems and Era Handling.

Gotchas and Common Pitfalls

Testing Checklist

Run your date suite under multiple TZ values so host-zone leakage fails loudly in CI.

Scenario Input Expected
Wall-clock day add over spring-forward 2024-03-09T12:00[America/New_York] .add({days:1}) 2024-03-10T12:00:00-04:00
Absolute hour add over spring-forward same .add({hours:24}) 2024-03-10T13:00:00-04:00
Nonexistent local time, reject 2024-03-10T02:30[America/New_York] RangeError
Feb 30 with reject overflow PlainDate.from('2024-02-30',{overflow:'reject'}) RangeError
Instant round-trip zdt.toInstant().toZonedDateTimeISO(tz) equals original
# Run the suite under several zones to catch host-zone assumptions.
for tz in UTC America/New_York Australia/Lord_Howe Asia/Kolkata; do
  TZ=$tz npx jest date
done

Frequently Asked Questions

Is the Temporal API ready for production?

Yes. Use @js-temporal/polyfill for broad support; it is stable and widely adopted. Native support ships in Chrome 144+ and Firefox 139+, with Safari progressing. Pin the polyfill version in package.json and conditionally skip it when a native Temporal is present.

How do I choose between PlainDate, Instant, and ZonedDateTime?

Ask whether you need a timezone. If the value is an absolute moment for storage or logs, use Instant. If you need wall-clock fields in a real zone for scheduling or display, use ZonedDateTime. If there is no timezone at all, use PlainDate, PlainTime, or PlainDateTime depending on which fields you need.

Can I migrate from legacy Date incrementally?

Yes. Bridge with Temporal.Instant.fromEpochMilliseconds(date.getTime()) to move into Temporal and new Date(Number(instant.epochMilliseconds)) to move back. Convert at the boundary and operate in Temporal internally.

Why does adding 24 hours give the wrong time across DST?

Because 24 hours is absolute elapsed time, and a spring-forward day is only 23 hours of wall clock. Use .add({ days: 1 }) on a ZonedDateTime to keep the clock time fixed; reserve .add({ hours }) for when you genuinely mean elapsed time.