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.
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
- Parsing a local string with
Temporal.Instant.from(). It requires aZor offset and throws otherwise. UsePlainDateTime.from(str).toZonedDateTime(tz)for wall-clock input. - Calling
Temporal.Duration.between(). It does not exist. Usestart.until(end)onInstant,PlainDate, orZonedDateTime. - Expecting the polyfill to set the global
Temporal. It does not — assignglobalThis.Temporal = Temporalyourself if you need it. - Using
.add({ hours: 24 })to mean "next day." Across DST that lands an hour off. Use.add({ days: 1 })on aZonedDateTimeto keep wall-clock time. - Storing numeric offsets instead of IANA zones. Offsets drift with DST and law changes. Store
Instant+ IANA id. - Formatting a
PlainDatewithouttimeZone: 'UTC'. The formatter treats it as UTC midnight and the host zone can shift the displayed day by one.
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.