JavaScript

The Temporal API: Finally Fixing JavaScript Dates (With Live Playground)

W
W3Tweaks Team
Frontend Tutorials
Jul 20, 202623 min read
Share:
The Temporal API: Finally Fixing JavaScript Dates (With Live Playground)
JavaScript's Date object has been broken for thirty years — mutable, zero-indexed months, and timezone handling that changes between browsers. The Temporal API fixes all of it. This guide has a live playground: watch Date mutate under you while Temporal stays frozen, pick the right type for any job, handle DST ambiguity with earlier/later/reject instead of silent wrong answers, plus a bundle-size showdown vs moment/date-fns/dayjs/Luxon, moment.js migration recipes, the definitive Instant-vs-ZonedDateTime storage pattern for backends, TypeScript patterns, and the Node.js/edge runtime support matrix.

TL;DR

Temporal is the ES2026 replacement for Dateimmutable objects, months numbered 1–12, first-class IANA timezones, correct DST. Ships natively in Chrome 144+, Firefox 145+, Node 24 LTS (Safari via @js-temporal/polyfill).

const today   = Temporal.Now.plainDateISO();              // 2026-07-20
const meeting = Temporal.ZonedDateTime.from('2026-07-20T14:00[America/New_York]');
const later   = today.add({ months: 1 });                 // NEW object, today unchanged
const diff    = today.until(Temporal.PlainDate.from('2026-12-25'));  // Duration

Watch out: three specific rules make Temporal safe where Date was broken —

  1. Date is mutable — functions can silently change your original. Every Temporal object is immutable: add()/subtract() return a new object.
  2. Months are 1-based in Temporal (.month === 6 for June) but 0-based in Date (getMonth() === 5 for June). The #1 off-by-one bug is gone.
  3. DST edge cases are explicit — the 2:30 AM that happens twice, or never (spring forward), forces you to pick earlier / later / compatible / reject. Date silently invented an answer; Temporal makes you decide.

Storage rule for backends: store Temporal.Instant (or epoch nanoseconds) as your source of truth, reconstruct ZonedDateTime in the user’s timezone at display time.

Try it in the live playground — pass a Date into a function and watch it mutate live, then run the same code on Temporal and see the original stay frozen. Trigger DST ambiguity errors on the London 2026-03-29 gap. Convert one instant across 5 timezones. Deep dive below for the moment.js/date-fns/dayjs/Luxon comparison, migration recipes, the Instant-vs-ZonedDateTime backend pattern, TypeScript patterns, and the Node.js/edge runtime support matrix.


If you have ever debugged a date bug at 2 AM — an appointment off by exactly one hour, a birthday that shifts across timezones, or a month that is somehow off by one — you have met JavaScript’s Date object. It was rushed into the language in 1995, loosely based on Java’s already-deprecated java.util.Date, and we have been working around its flaws ever since. The Temporal API is the fix.

Temporal reached TC39 Stage 4 in March 2026 and is now part of the ES2026 specification, shipping natively in Chrome 144+, Firefox 145+, and Edge 144+ (Safari is available via the official @js-temporal/polyfill). It replaces Date with immutable objects, correct month numbering, nanosecond precision, and first-class timezone and calendar support. This guide runs everything live: watch a Date mutate under you while Temporal stays frozen, pick the right type for any job, convert timezones, and handle the daylight-saving-time edge cases that silently break Date.

This is the first tutorial in our Modern JavaScript Features series. Temporal pairs naturally with the Intl API for formatting dates per locale, and its immutability echoes the copy-vs-mutate ideas in the non-mutating array methods guide.


Live Demo

Live DemoOpen in tab

Tab 1: watch Date mutate while Temporal stays immutable, plus pick the right type per use case. Tab 2: the DST disambiguation playground with earlier/later/reject. Tab 3: a multi-timezone meeting board and live date-diff calculator.


Why Date Is Broken

Three design flaws have caused a generation of bugs. All three are visible in the demo’s first tab.

1. Date is mutable. Pass a Date into a function and that function can silently change your original — no warning, no error:

const start = new Date('2026-01-01');

function addWeek(date) {
  date.setDate(date.getDate() + 7);   // mutates the argument!
  return date;
}

addWeek(start);
console.log(start);   // 2026-01-08 — the ORIGINAL changed

2. Months are zero-indexed. January is 0, December is 11 — but days start at 1. This inconsistency is a permanent source of off-by-one bugs:

new Date(2026, 0, 15);   // January 15 — month 0 is January
new Date(2026, 12, 15);  // NOT December — rolls over to January 2027

3. Timezone parsing is browser-dependent. The same “almost-ISO” string parses differently across browsers — Chrome may read it as local time, Safari as UTC, Firefox may throw. Your code produces different results on different machines.

Temporal fixes all three: every object is immutable, months run 112, and parsing only accepts strictly defined formats, throwing on anything ambiguous.


Temporal vs moment.js vs date-fns vs dayjs vs Luxon

Every senior dev evaluating a migration lands here first. The honest 2026 comparison:

LibraryBundle (min+gz)Tree-shakesMutableTimezone supportDST-safeCalendarsMigration effort
Temporal (native)0 KB — built-inN/A❌ immutable✅ IANA built-in✅ explicit✅ Hebrew, Islamic, Japanese, etc.Fresh API
Temporal (polyfill)~35 KBPartial❌ immutable✅ IANA built-in✅ explicitSame API
date-fns 4~16 KB modular✅ full❌ immutablevia @date-fns/tz add-on❌ Gregorian onlyLow
dayjs 1.11~2 KB + pluginsPlugin-based❌ immutablePlugin (utc, timezone)⚠️ plugin-dependentLow (Moment-like API)
Luxon~23 KBPartial❌ immutable✅ via IntlSome (via Intl)Medium
moment.js 2.3067 KB (+~30 KB TZ data)✅ mutablevia moment-timezone⚠️Native — maintenance-only since 2020

The takeaways: Temporal is 0 KB in supported runtimes and 35 KB with the polyfill — cheaper than everything except vanilla dayjs. It’s the only option with built-in IANA timezone data (moment-timezone ships ~30 KB of it separately; date-fns needs an add-on; dayjs needs a plugin). And non-Gregorian calendars — Hebrew, Islamic, Japanese, Buddhist, Persian — are a first-class citizen, which no JS date library has ever offered natively.

For new projects, Temporal is the answer. For maintenance on moment.js codebases (moment still has ~20M weekly downloads despite being in maintenance-only mode since 2020), the migration recipes below cover the 10 patterns you’ll actually hit.


The Temporal Types: One Tool Per Job

Instead of cramming everything into one Date, Temporal splits functionality into distinct, purpose-built types. Choosing the right one makes your intent explicit:

TypeRepresentsUse for
Temporal.PlainDateA calendar date, no timeBirthdays, deadlines, holidays
Temporal.PlainTimeA wall-clock time, no dateOpening hours, alarms
Temporal.PlainDateTimeDate + time, no timezoneForm input before applying a zone
Temporal.ZonedDateTimeDate + time + IANA timezoneThe safe choice for real events
Temporal.PlainYearMonthYear + monthCredit-card expiry, “2026-04”
Temporal.PlainMonthDayMonth + day, no yearRecurring dates, anniversaries
Temporal.InstantAn exact point on the global timelineTimestamps, logging
Temporal.DurationA length of time”3 days, 4 hours”

The rule of thumb: when in doubt, reach for ZonedDateTime — it carries the timezone with it and handles DST correctly. Use the Plain* types when the timezone genuinely does not matter (a birthday is the same date everywhere).

This is why every scheduling tool from Calendly to Cal.com stores the meeting’s instant and the invitee’s IANA zone separately — a booking made at 3pm New York must still read 3pm New York if the organizer flies to London. Reclaim.ai and Motion apply the same rule for auto-scheduling around DST. And PTO systems like Rippling, Deel, BambooHR, and Gusto are the canonical Temporal.PlainDate use case — an employee’s vacation on July 4 is July 4 regardless of what timezone the HR admin approves it from.


Creating Temporal Objects

Every type has a from() method that accepts a string or an object. Note months are 1-based:

// From a string
const birthday = Temporal.PlainDate.from('2026-06-27');
console.log(birthday.dayOfWeek);   // 6 (Saturday)
console.log(birthday.month);       // 6 — June is actually 6!

// From an object
const date = Temporal.PlainDate.from({ year: 2026, month: 2, day: 7 });

// The current moment, in a specific type
const today = Temporal.Now.plainDateISO();               // just the date
const nowHere = Temporal.Now.zonedDateTimeISO();          // date+time+your zone
const nowSeoul = Temporal.Now.zonedDateTimeISO('Asia/Seoul');

// A zoned date-time carries its IANA zone in brackets
const meeting = Temporal.ZonedDateTime.from('2026-06-27T14:00:00[America/New_York]');
console.log(meeting.toString());   // 2026-06-27T14:00:00-04:00[America/New_York]

The ISO suffix on Now methods means “use the ISO 8601 calendar” — the same calendar Date uses and the one you want unless you specifically need another.


Immutable Date Math

Every Temporal object is immutable, so add() and subtract() return a new object and never touch the original — the exact opposite of Date.setDate():

const today = Temporal.PlainDate.from('2026-02-07');

const later = today.add({ days: 30 });
console.log(later.toString());   // 2026-03-09
console.log(today.toString());   // 2026-02-07 — unchanged!

// Calendar-aware: "1 month" means a real calendar month, not 30 days
const jan31 = Temporal.PlainDate.from('2026-01-31');
console.log(jan31.add({ months: 1 }).toString());   // 2026-02-28, not March 3

That last line matters: Date arithmetic on January 31 + 1 month overflowed into March. Temporal clamps to the real end of February. Stripe Billing, Chargebee, and Recurly all implement this month-end clamping rule — a January 31 subscription renewing monthly must charge on February 28, not March 3, and Temporal’s add({months: 1}) does it out of the box.

Differences Between Dates

until() and since() give you a Duration instead of raw milliseconds:

const start = Temporal.PlainDate.from('2026-01-01');
const end   = Temporal.PlainDate.from('2026-02-07');

const diff = start.until(end);
console.log(diff.days);   // 37

// Ask for larger units
const bigger = start.until(end, { largestUnit: 'month' });
console.log(`${bigger.months} months, ${bigger.days} days`);   // 1 months, 6 days

Time trackers like Toggl, Harvest, and Clockify live and die on since() and until() — a work session is a Duration between two ZonedDateTimes, and rounding it to billable increments is one call to .round({smallestUnit: 'minute', roundingIncrement: 15}).


Timezones Without the Headache

Converting between zones was one of Date’s worst areas. With Temporal it is a single withTimeZone() call, and it stays DST-correct:

const seoul = Temporal.Now.zonedDateTimeISO('Asia/Seoul');
console.log(seoul.toString());   // ...+09:00[Asia/Seoul]

const newYork = seoul.withTimeZone('America/New_York');
console.log(newYork.toString());   // same instant, ...-05:00[America/New_York]

Both objects represent the same instant on the global timeline — just displayed in different local wall-clock time. The demo’s third tab shows one meeting time across five cities, all derived from a single instant.

Analytics platforms from GA4 to Mixpanel, Amplitude, and PostHog store events as UTC instants and re-bucket to the viewer’s zone at query time — the exact pattern Temporal now makes trivial with instant.toZonedDateTimeISO(userZone). Datadog, Sentry, New Relic, and Grafana Loki serialize every log line as an epoch nanosecond precisely because Temporal.Instant is what a log entry actually is — a point in time, zone-free, calendar-free.


The DST Trap: Ambiguous and Skipped Times

This is where Date silently lied and Temporal makes you think. Twice a year, clock time gets weird:

Skipped times (spring forward). When London springs forward on 2026-03-29, the clock jumps 01:00 → 02:00, so 01:30 never exists. Date would invent an arbitrary answer. Temporal lets you choose:

const pdt = Temporal.PlainDateTime.from('2026-03-29T01:30:00');

// This time doesn't exist in London that day:
pdt.toZonedDateTime('Europe/London', { disambiguation: 'reject' });
// → RangeError: this time does not exist

pdt.toZonedDateTime('Europe/London', { disambiguation: 'earlier' }).toString();
// → picks 00:30 (before the gap)
pdt.toZonedDateTime('Europe/London', { disambiguation: 'later' }).toString();
// → picks 02:30 (after the gap)

Ambiguous times (fall back). When clocks fall back, 01:30 happens twice. The four disambiguation strategies are:

  • 'compatible' (default) — matches Date’s behaviour, picks the earlier instance for gaps
  • 'earlier' — the first occurrence
  • 'later' — the second occurrence
  • 'reject' — throw a RangeError and force you to decide

The demo’s second tab lets you pick a DST date and a strategy and see exactly which instant you get — or the error. This is the single biggest reason to move off Date: it never told you the time was ambiguous. Airlines learned this the hard way — a flight scheduled to depart during the fall-back hour is exactly why Google Flights, Expedia, Booking.com, and Kayak pin schedules to specific instants, not wall clocks. Zoom, Google Meet, Microsoft Teams, and Webex hit the same wall at scale — recurring meetings anchored to wall-clock time survive DST, meetings anchored to a fixed instant drift by an hour twice a year.

DST-Aware Arithmetic

When you do math on a ZonedDateTime, Temporal accounts for DST automatically. Adding “1 day” across a DST boundary keeps the wall-clock time correct even though the actual elapsed hours differ:

const before = Temporal.ZonedDateTime.from('2026-03-28T12:00:00[Europe/London]');
const after = before.add({ days: 1 });
console.log(after.toString());   // 2026-03-29T12:00:00 — still noon, though only 23 real hours passed

The Storage Pattern: Instant in the Database, ZonedDateTime at Display

This is THE backend question and it has a definitive answer:

Store Temporal.Instant (or epoch nanoseconds) as your source of truth. Reconstruct ZonedDateTime in the user’s timezone at display time.

// Write path
const eventInstant = someZonedDateTime.toInstant();
await db.events.insert({
  happened_at: eventInstant.toString(),         // PostgreSQL timestamptz
  // or, if the column is BIGINT for nanosecond precision:
  happened_at_ns: eventInstant.epochNanoseconds
});

// Read path
const row = await db.events.findOne(...);
const instant = Temporal.Instant.from(row.happened_at);
const localTime = instant.toZonedDateTimeISO(currentUser.timezone);

PostgreSQL timestamptz maps 1:1 to Temporal.Instant — the column stores UTC internally and hands you back a strict ISO string. Store the user’s IANA zone ('America/New_York') in a separate column on the user record, never baked into the timestamp column.

The one exception — wall-clock-anchored events. For a recurring “9am team meeting” or a “birthday on July 4”, store Temporal.PlainDateTime (or PlainDate) plus the timezone string as separate columns, not as a computed ZonedDateTime. Why: DST rules can change retroactively (governments alter timezone rules — Egypt did in 2023, Chile every year). Storing the wall-clock intent lets you reconstruct the correct instant at read time using current tzdata, rather than being stuck with an instant computed under an obsolete rule.


Node.js and Edge Runtime Support

Serverless devs need the exact status. As of 2026:

RuntimeNative Temporal supportNotes
Node.js 22 LTSBehind --js-temporal flagShip polyfill in prod
Node.js 24 LTS✅ NativeZero-dep, zero-runtime-cost
Deno 2.1+✅ NativeShips with V8 that has it
Bun 1.2+✅ NativeJavaScriptCore-based, tracks TC39 fast
Cloudflare Workers✅ via compatibility_flags = ["temporal_api"] in wrangler.tomlLanded 2026
Vercel Edge Functions❌ polyfill requiredV8 isolate without the flag
Netlify Edge Functions❌ polyfill requiredSame
AWS LambdaNode 22 (flag) / Node 24 (native)Match your runtime version

The migration path for serverless: ship the polyfill unconditionally. It’s ~35 KB gzipped, the API matches native exactly, and it lets you write one codebase that runs on every runtime without version-sniffing. Drop the polyfill when your target runtime hits native support.


Migration Recipes From moment.js

Ten side-by-side conversions covering ~90% of moment.js usage:

moment.jsTemporal
moment()Temporal.Now.zonedDateTimeISO()
moment().format('YYYY-MM-DD')Temporal.Now.plainDateISO().toString()
moment().format('LLL')Temporal.Now.zonedDateTimeISO().toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short' })
moment().add(1, 'month')zdt.add({ months: 1 })
moment().subtract(3, 'days')zdt.subtract({ days: 3 })
moment().startOf('month')zdt.with({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 })
moment().endOf('day')zdt.with({ hour: 23, minute: 59, second: 59, millisecond: 999 })
moment(a).isBefore(b)Temporal.ZonedDateTime.compare(a, b) < 0
moment.tz(str, 'America/New_York')Temporal.ZonedDateTime.from(`${str}[America/New_York]`)
moment.duration(90, 'minutes').humanize()Temporal.Duration.from({ minutes: 90 }).round({ largestUnit: 'hour' }).toLocaleString('en-US', { style: 'long' })

The two footguns during migration:

  1. Comparison — moment returns a boolean from .isBefore(), Temporal returns -1/0/1 from .compare(). Wrap it in < 0, > 0, or === 0.
  2. Duration humanization — Temporal’s .toLocaleString() needs an explicit style option; humanize() had smart defaults.

TypeScript Patterns

Two patterns you’ll write repeatedly:

Discriminated union of date types. Some functions accept “a date or a datetime — pick one”:

type AppTime = Temporal.PlainDate | Temporal.ZonedDateTime;

function isZoned(t: AppTime): t is Temporal.ZonedDateTime {
  return 'timeZoneId' in t;
}

function formatForUser(t: AppTime, userZone: string): string {
  const zdt = isZoned(t) ? t.withTimeZone(userZone) : t.toZonedDateTime(userZone);
  return zdt.toLocaleString('en-US');
}

The polyfill ships types. @js-temporal/polyfill includes complete TypeScript definitions — the same shape native runtimes will expose via lib.es2026.temporal.d.ts once it lands. Import the polyfill and you have working types on any TS version:

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

const meeting: Temporal.ZonedDateTime = Temporal.ZonedDateTime.from(
  '2026-07-20T14:00:00[America/New_York]'
);

For projects using the native API only, you’ll want @types/temporal (a community-maintained shim) until the built-in lib file lands in TypeScript.


Formatting for Humans

Temporal objects work directly with Intl.DateTimeFormat and toLocaleString for localized output:

const date = Temporal.PlainDate.from('2026-08-24');

date.toLocaleString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
// "August 24, 2026"

date.toLocaleString('de-DE', { dateStyle: 'full' });
// "Montag, 24. August 2026"

For the full breakdown of locale-aware formatting, see the upcoming Intl API tutorial in this series.


What Temporal Deliberately Does Not Do

Temporal is the primitive, not the batteries. Three things it deliberately leaves to the ecosystem:

  • Recurring events (RRULE) — use rrule.js and convert results to Temporal.Instant via the returned Date.toTemporalInstant(). Project management platforms like Linear, Asana, Notion, ClickUp, and Jira all wrap rrule.js for repeating tasks and convert results to Temporal instants for display.
  • Business day / working hours math — Temporal doesn’t ship “next business day”. Keep date-fns/isBusinessDay and interop through ISO strings.
  • Cron parsing — libraries like croner and node-cron compute next-fire times as Date; convert to ZonedDateTime for display.

These aren’t gaps — they’re a deliberate scoping decision. The core API stays small and correct; specialized libraries do specialized work.


Should You Adopt It Today?

For new projects, yes. Temporal covers roughly 95% of what moment.js, date-fns, and dayjs do, with zero bundle-size cost where it is natively supported. For Safari and older browsers, add the @js-temporal/polyfill — it is the stable, recommended option and lets you ship the same API everywhere while native support finishes rolling out.

For existing projects, migrate gradually: store a Temporal.Instant (or epoch nanoseconds) as your source of truth, and reconstruct a ZonedDateTime in the user’s timezone at display time. You do not have to rewrite everything at once.


Key Takeaways

  • The Temporal API replaces Date, fixing three decades of design flaws — it reached ES2026 Stage 4 and ships in Chrome 144+, Firefox 145+, Node 24 LTS, Deno 2.1+, and Bun 1.2+ natively; Cloudflare Workers via compatibility_flags; polyfill for Vercel/Netlify Edge and Safari
  • Date is mutable (functions can silently change your original), zero-indexed for months, and parses inconsistently across browsers — Temporal fixes all three
  • Temporal splits time into distinct typesPlainDate, PlainTime, PlainDateTime, ZonedDateTime, Instant, Duration, and more — so your intent is explicit
  • When in doubt, use ZonedDateTime — it carries the IANA timezone and handles DST correctly
  • Every Temporal object is immutable: add() and subtract() return new objects and never mutate the original
  • Month arithmetic is calendar-aware — January 31 + 1 month clamps to the end of February instead of overflowing
  • until() and since() return a Duration instead of raw milliseconds, with a largestUnit option
  • Timezone conversion is a single withTimeZone() call and preserves the exact instant
  • DST disambiguation (earlier, later, compatible, reject) forces you to handle skipped and repeated clock times that Date silently got wrong
  • The definitive storage pattern: Temporal.Instant (or epoch nanoseconds) in the database, ZonedDateTime reconstructed at display time with the user’s IANA zone stored separately; exception for wall-clock-anchored events (recurring meetings, birthdays) which store PlainDateTime + zone string to survive retroactive tzdata changes
  • Bundle-size winner: 0 KB native, ~35 KB polyfill — beats moment (67 KB) and Luxon (23 KB), matches date-fns modular (16 KB), only vanilla dayjs (2 KB) is lighter
  • Non-Gregorian calendars (Hebrew, Islamic, Japanese, Buddhist, Persian) are first-class — no other JS date library offers this natively
  • Moment.js migration is straightforward: .add()/.subtract() map directly, comparisons switch from booleans to Temporal.ZonedDateTime.compare() returning -1/0/1, and moment.tz(str, zone) becomes `${str}[${zone}]` in the string form
  • TypeScript: @js-temporal/polyfill ships complete types; use 'timeZoneId' in value for type narrowing between PlainDateTime and ZonedDateTime
  • For Safari and older browsers, use @js-temporal/polyfill; for existing apps, store Instant and reconstruct ZonedDateTime at display time

FAQ

What is the Temporal API and why is it better than Date?

Temporal is JavaScript’s modern date-time API, part of ES2026, that replaces the legacy Date object. It is better because every object is immutable (no accidental mutation when passing dates to functions), months are numbered 1–12 like a real calendar, it has first-class IANA timezone support via ZonedDateTime, it offers nanosecond precision, and it only parses strictly defined string formats so results are consistent across browsers. Date fails on all of these.

Which Temporal type should I use?

Match the type to your intent. Use PlainDate for dates without a time (birthdays, deadlines), PlainTime for times without a date (opening hours), PlainDateTime for a date and time before you know the timezone, and ZonedDateTime for real events tied to a location. Use Instant for timestamps, Duration for lengths of time, and PlainYearMonth/PlainMonthDay for partial dates. When unsure, start with ZonedDateTime since it handles timezones and DST correctly.

Is the Temporal API supported in all browsers and Node?

Native support in 2026: Chrome 144+, Firefox 145+, Edge 144+, Node 24 LTS, Deno 2.1+, Bun 1.2+. Node 22 supports it behind the --js-temporal flag. Cloudflare Workers enables it via compatibility_flags = ["temporal_api"] in wrangler.toml. Vercel Edge Functions and Netlify Edge Functions do not yet ship it natively; Safari also lags. For those, install the official @js-temporal/polyfill (~35 KB gzipped) and ship the same code everywhere.

How does Temporal handle daylight saving time?

Temporal makes DST explicit instead of guessing. When a wall-clock time is skipped (spring forward) or repeated (fall back), you supply a disambiguation option: earlier, later, compatible (the default, matching old Date behaviour), or reject (which throws a RangeError for impossible times). Arithmetic on a ZonedDateTime is also DST-aware, so adding one day across a boundary keeps the correct wall-clock time even though the elapsed hours differ.

Do I still need moment.js, date-fns, or dayjs with Temporal?

For new projects, largely no. Temporal covers about 95% of what those libraries do — parsing, math, timezones, durations, and formatting via Intl — natively and with zero bundle cost where supported. You’ll keep a utility library only for the deliberate omissions: business-day math (date-fns), recurring events via RRULE (rrule.js), and cron scheduling (croner). moment.js in particular has been in maintenance-only mode since 2020; the migration recipes above cover the common patterns.

Why are months 1-based in Temporal but 0-based in Date?

The original Date copied Java’s zero-indexed months, where January is 0 and December is 11, while days start at 1 — an inconsistency that caused countless off-by-one bugs. Temporal corrects this: months run 1 through 12, matching how humans and every real calendar count them. So Temporal.PlainDate.from('2026-06-27').month returns 6 for June, not 5.

Should I store Instant or ZonedDateTime in my database?

Store Temporal.Instant (as an ISO string in a PostgreSQL timestamptz column or as epoch nanoseconds in a BIGINT) as your source of truth. Store the user’s IANA timezone (like 'America/New_York') in a separate column on the user record. At display time, reconstruct with instant.toZonedDateTimeISO(user.timezone). The one exception: for wall-clock-anchored events (a recurring 9am meeting or a birthday on July 4), store PlainDateTime (or PlainDate) plus the zone as separate columns — that way, if government DST rules change retroactively, your event reconstructs against the current tzdata rather than being stuck with an obsolete instant.

How do I compare two Temporal objects?

Use the static compare() method on the type: Temporal.PlainDate.compare(a, b) returns -1 if a is earlier, 0 if equal, 1 if a is later. Wrap it in an inequality: if (Temporal.PlainDate.compare(a, b) < 0) { … }. This is one of the two big footguns migrating from moment.js — moment’s .isBefore() returned a boolean, Temporal returns a tri-state number so you can also sort with it.

Is Temporal safe to use as React or Vue component state?

Yes — every Temporal object is immutable, so it behaves exactly like a primitive from React’s perspective. Storing a Temporal.ZonedDateTime in useState works cleanly, comparisons in useEffect dependency arrays use referential equality (create a new object only when you actually mean to change the state), and there’s no “did the caller mutate my prop” concern that Date always had.

What’s the bundle size cost of Temporal vs date-fns or moment.js?

Native Temporal in Chrome 144+/Firefox 145+/Node 24 is 0 KB — it’s built into the runtime. The @js-temporal/polyfill for older browsers and edge runtimes is about 35 KB min+gzip. Compare that to date-fns 4 at ~16 KB modular (tree-shakes well), dayjs at ~2 KB plus plugins for real features, Luxon at ~23 KB, and moment.js 2.30 at 67 KB (plus another ~30 KB for moment-timezone for full IANA coverage). Temporal is the smallest option that ships full IANA timezone data — no add-on needed.