TC39 moved Temporal to Stage 4 in March 2026, so it’s part of ECMAScript 2026. Node.js 26 shipped on May 5 with Temporal enabled by default. Chrome and Edge have had it since version 144 (January 2026), and Firefox since 139 (May 2025).

Whether to move off Date and Moment.js is settled. The open questions are practical ones. How do my Moment calls translate? Do I still need a polyfill? What happens to the dates in my JSON and my database?

This guide answers those in the order you’ll hit them. If you’re here for the Node 26 upgrade itself, the Node.js 26 upgrade guide covers type stripping, the ABI bump and the other breaking changes.

Temporal

Should you migrate now?

New code: yes. Old code: gradually. Libraries: not yet.

On Node 26, Temporal costs nothing to use. In the browser, Safari and iOS Safari still don’t ship it outside Technology Preview, and caniuse puts global support at about 69%. Browser apps need a polyfill for now.

If you publish an npm package, a Temporal dependency sets a Node 26 floor for your users. Node 24 LTS is supported until April 2028, so most of them aren’t there yet.

Why Date had to be replaced

Date shipped in 1995, copied from Java’s java.util.Date. Java deprecated most of that class two years later. JavaScript kept it.

Its problems are well known. It’s mutable, so date.setMonth(13) changes every reference to that object. Months start at zero. new Date('2026-03-29') parses as UTC, but new Date(2026, 2, 29) parses as local time. The only time zone information is getTimezoneOffset(), a minute count that knows nothing about IANA zones like Europe/London or when DST starts. Precision stops at milliseconds.

The test suites show the difference in scope. According to Igalia, test262 has 594 tests for Date and about 4,500 for Temporal.

Moment, date-fns, Luxon and Day.js all exist to work around Date. Temporal replaces it.

The Temporal types

Temporal is a global namespace, like Math or Intl. Date uses one object for every date and time concept. Temporal gives each concept its own type:

Type Represents Use it for
Temporal.Instant An exact point in time (epoch nanoseconds) Log timestamps, createdAt, elapsed time
Temporal.ZonedDateTime An instant plus an IANA time zone Meetings, reminders, anything that crosses DST
Temporal.PlainDate A calendar date with no time or zone Birthdays, due dates, holidays
Temporal.PlainTime A wall-clock time “Store opens at 09:00”
Temporal.PlainDateTime Date and time, no zone Form input before you know the user’s zone
Temporal.PlainYearMonth A year and month Billing periods, card expiry
Temporal.Duration A length of time SLAs, retry windows, “1 year 2 months”

Non-Gregorian calendars are string IDs, not a type. Early versions of the proposal had Temporal.Calendar and Temporal.TimeZone classes; both were removed in 2024. You now write date.withCalendar('hebrew') and read zdt.timeZoneId.

Every Temporal object is immutable:

javascript
const invoiceDate = Temporal.PlainDate.from('2026-08-18');
const due = invoiceDate.add({ days: 30 });

due.toString();          // '2026-09-17'
invoiceDate.toString();  // '2026-08-18', unchanged

Time zone conversion needs no extra package, because the runtime ships the IANA database:

javascript
const call = Temporal.ZonedDateTime.from('2026-10-05T09:00[Asia/Tokyo]');
call.withTimeZone('Europe/London').toString();
// '2026-10-05T01:00:00+01:00[Europe/London]'

Moment.js, date-fns, Day.js, Luxon or Temporal?

Sizes are min+gzip from Bundlephobia.

Moment.js date-fns 4 Day.js Luxon Temporal
Status Maintenance mode Active Active Active ES2026 standard
Size 20.3 kB, plus locales 17.5 kB full; far less per function 3.1 kB core, plus plugins 21.9 kB 0 on Node 26, Chrome, Firefox; ~19 kB polyfill elsewhere
Immutable No Yes (returns new Dates) Yes Yes Yes
Time zones moment-timezone @date-fns/tz Plugin Built in Built in
Non-Gregorian calendars No No No Via Intl Yes
Types Bundled Bundled Bundled @types/luxon TypeScript 6.0 lib
Token formatting (YYYY-MM-DD) Yes Yes Yes Yes No; Intl only

Here’s how I’d choose:

  • New Node 26 service: Temporal. No dependency.
  • New browser app: Temporal plus temporal-polyfill, loaded only where it’s missing. If ~19 kB for Safari users is too much, use date-fns 4 with @date-fns/tz and revisit once Safari ships.
  • Existing Moment app: migrate module by module (steps below). Moving to Day.js first is only worth it if bundle size is the immediate problem, since you’d migrate twice.
  • Heavy time zone or calendar logic: Temporal. That’s the case it was designed for.
  • Existing date-fns or Luxon app: no rush. Both are maintained. Write new code in Temporal and let the old code age out.

Moment’s maintainers have called it a legacy project since 2020. It still gets time zone data updates and fixes, but no new features, no ES modules and no tree-shaking. Keeping it in a legacy app is fine. Adding new Moment code isn’t.

Moment and date-fns to Temporal: cheat sheet

This is the table most people are looking for. date is a PlainDate, zdt a ZonedDateTime and inst an Instant.

Task Moment.js date-fns Temporal
Now moment() new Date() Temporal.Now.zonedDateTimeISO()
Now, UTC timestamp moment.utc() new Date() Temporal.Now.instant()
Today’s date moment().startOf('day') startOfToday() Temporal.Now.plainDateISO()
Parse ISO date moment('2026-03-29') parseISO('2026-03-29') Temporal.PlainDate.from('2026-03-29')
Parse in a zone moment.tz('2026-03-29 09:00', 'Europe/London') new TZDate(2026, 2, 29, 9, 'Europe/London') Temporal.ZonedDateTime.from('2026-03-29T09:00[Europe/London]')
Add m.add(7, 'days') (mutates) addDays(d, 7) date.add({ days: 7 })
Subtract m.subtract(1, 'month') subMonths(d, 1) date.subtract({ months: 1 })
Start of day m.startOf('day') startOfDay(d) zdt.startOfDay()
Start of month m.startOf('month') startOfMonth(d) date.with({ day: 1 })
End of month m.endOf('month') endOfMonth(d) date.with({ day: date.daysInMonth })
Difference in days b.diff(a, 'days') differenceInCalendarDays(b, a) a.until(b).days
Difference in hours b.diff(a, 'hours', true) differenceInHours(b, a) a.until(b).total({ unit: 'hour' })
Is before a.isBefore(b) isBefore(a, b) Temporal.PlainDate.compare(a, b) < 0
Same day a.isSame(b, 'day') isSameDay(a, b) a.toPlainDate().equals(b.toPlainDate())
Convert zone m.tz('Asia/Tokyo') d.withTimeZone('Asia/Tokyo') (TZDate) zdt.withTimeZone('Asia/Tokyo')
ISO string m.format('YYYY-MM-DD') format(d, 'yyyy-MM-dd') date.toString()
Display format m.format('D MMM YYYY') format(d, 'd MMM yyyy') date.toLocaleString('en-GB', { dateStyle: 'medium' })
Epoch ms m.valueOf() d.getTime() inst.epochMilliseconds
From a Date moment(d) n/a d.toTemporalInstant()
Relative time m.fromNow() formatDistanceToNow(d) No built-in; see below

Two things catch everyone the first time.

Comparison operators throw. Temporal objects have no usable valueOf(), so a < b raises a TypeError instead of quietly comparing strings. Use Temporal.X.compare(a, b) for sorting and .equals() for equality:

javascript
invoices.sort((a, b) => Temporal.PlainDate.compare(a.dueOn, b.dueOn));

until() and since() return a Duration. A PlainDate difference is in days by default. For other units, pass largestUnit or call .total():

javascript
const start = Temporal.PlainDate.from('2026-01-15');
const end = Temporal.PlainDate.from('2026-09-17');

start.until(end).days;                              // 245
start.until(end, { largestUnit: 'month' }).toString(); // 'P8M2D'

Formatting without tokens

Temporal has no format('YYYY-MM-DD'). For machine-readable output, toString() already returns ISO 8601. For display, toLocaleString() passes your options to Intl.DateTimeFormat:

javascript
const zdt = Temporal.ZonedDateTime.from('2026-09-17T14:30[Asia/Kolkata]');

zdt.toLocaleString('en-IN', { dateStyle: 'long', timeStyle: 'short' });
// '17 September 2026 at 2:30 pm'

The exact output depends on the ICU data in your runtime, so don’t assert on formatted strings in tests.

If your UI depends on dozens of custom format strings, keep a small formatting module that wraps Intl.DateTimeFormat instances. Creating a formatter is slower than calling one, so cache them by locale and options.

Relative time: fromNow()

Intl.RelativeTimeFormat does the wording. You pick the unit:

javascript
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
const UNITS = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'];

export function fromNow(zdt, now = Temporal.Now.zonedDateTimeISO(zdt.timeZoneId)) {
  const diff = zdt.since(now, { largestUnit: 'year', smallestUnit: 'second' });
  const unit = UNITS.find((u) => diff[u] !== 0) ?? 'seconds';
  return rtf.format(diff[unit], unit.slice(0, -1));
}

const now = Temporal.ZonedDateTime.from('2026-09-17T10:00[Europe/London]');
fromNow(now.subtract({ minutes: 3 }), now); // '3 minutes ago'
fromNow(now.add({ days: 1 }), now);         // 'tomorrow'

Pass now explicitly when you compare against a value derived from the current time. Otherwise the clock moves between the two reads, and “1 day from now” prints as “in 23 hours”.

This truncates: 1 month and 20 days prints as “last month”. If you need Moment’s rounding thresholds, round the duration with diff.round({ largestUnit: unit, smallestUnit: unit, relativeTo: now }) before formatting.

TypeScript setup

TypeScript 6.0 added Temporal types. Include them through lib:

json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": ["esnext", "dom"]
  }
}

For narrower libs, esnext.temporal adds the namespace and esnext.date adds Date.prototype.toTemporalInstant(). On TypeScript 5.x, the temporal-spec package supplies the types. See TypeScript 6.0 breaking changes if you haven’t upgraded yet.

Pick a polyfill

There are two, and they behave differently.

temporal-polyfill (FullCalendar) @js-temporal/polyfill
Version 1.0.x 0.5.x, labelled alpha
Size (min+gzip) ~19 kB (~23 kB with all calendars) ~52 kB
Installs a global Yes, via temporal-polyfill/global, and only if Temporal is missing No; you import Temporal from the package
Calendars ISO and Gregorian by default; others from /full All

For an app, temporal-polyfill/global is the one to use. It does nothing where Temporal is native, so Node 26 and Chrome users run the engine’s version.

The import still adds bytes to every user’s bundle. To ship it only to browsers that need it, load it on demand at your entry point:

javascript
// src/temporal.js - import this before anything that uses Temporal
if (typeof globalThis.Temporal === 'undefined') {
  await import('temporal-polyfill/global');
}

That relies on top-level await, so the entry must be an ES module. If you’re still on CommonJS or UMD, the ESM migration guide covers the switch.

Avoid @js-temporal/polyfill on Node 26. It never checks for the native global, so you end up with two Temporal implementations whose objects fail instanceof checks against each other.

JSON and database boundaries

Temporal objects serialize to strings. JSON.stringify calls toJSON(), which emits RFC 9557 strings such as 2026-09-17T09:00:00Z or 2026-09-17T14:30:00+05:30[Asia/Kolkata]. JSON.parse gives you those strings back, not Temporal objects. Revive them by key:

javascript
const REVIVERS = {
  createdAt: Temporal.Instant,
  dueOn: Temporal.PlainDate,
  meetingAt: Temporal.ZonedDateTime,
};

export const parseApiBody = (text) =>
  JSON.parse(text, (key, value) =>
    typeof value === 'string' && REVIVERS[key] ? REVIVERS[key].from(value) : value
  );

If you already validate request bodies, do the conversion in the schema. A Zod .transform() or a Joi custom rule works; see Joi vs Zod input validation.

Database drivers don’t know about Temporal. The MongoDB driver and pg both expect Date. Store by type:

  • Instant or ZonedDateTime: store a Date from new Date(inst.epochMilliseconds). Keep the zone ID in a separate field if you need to show the time in the user’s zone later. MongoDB TTL indexes only work on BSON Date fields, so this matters for expiring documents.
  • PlainDate: store the YYYY-MM-DD string or a SQL date column. Storing it as midnight UTC reintroduces the off-by-one-day bug you’re migrating away from.
  • Reading back: row.createdAt.toTemporalInstant().

In Mongoose, getters and setters on the schema path keep this out of your services. The Node.js 26 guide has the full conversion snippet.

Testing code that calls Temporal.Now

Tests that depend on the current time are flaky, with or without Temporal. The clean fix is to pass the clock in:

javascript
export function isOverdue(invoice, today = Temporal.Now.plainDateISO()) {
  return Temporal.PlainDate.compare(invoice.dueOn, today) < 0;
}

// test
assert.equal(isOverdue(invoice, Temporal.PlainDate.from('2026-10-01')), true);

For code you can’t change yet, replace the method for one test. With Node’s built-in node:test runner:

javascript
import { test } from 'node:test';

test('overdue after due date', (t) => {
  t.mock.method(Temporal.Now, 'plainDateISO', () => Temporal.PlainDate.from('2026-10-01'));
  // ...
});

Don’t assume fake-timer utilities cover Temporal. Many of them patch Date and the timer functions only, so check yours before trusting a green test.

Migrating an existing codebase

You don’t need a rewrite. This is the order I’d follow.

1. Add the polyfill and types. Browser apps need the loader above. Node 26 services need nothing. Set lib as shown in the TypeScript section.

2. Block new Moment imports. no-restricted-imports in ESLint states the rule and the reason:

javascript
// eslint.config.js
export default [
  {
    rules: {
      'no-restricted-imports': ['error', {
        paths: [
          { name: 'moment', message: 'Use Temporal. See src/lib/dates.js.' },
          { name: 'moment-timezone', message: 'Use Temporal.ZonedDateTime.' },
        ],
      }],
    },
  },
  { files: ['src/legacy/**'], rules: { 'no-restricted-imports': 'off' } },
];

3. Find where date code lives. Usage tends to cluster in a few utility, billing and reporting modules. Count imports per file:

bash
git grep -cE "from ['\"](moment|moment-timezone|date-fns|dayjs|luxon)" -- src \
  | sort -t: -k2 -nr | head -20

4. Rewrite the utility layer first. Change the internals of formatDate(), addBusinessDays() and parseUserDate() and keep their signatures. If they take and return Date, convert inside with toTemporalInstant() and epochMilliseconds. Call sites don’t change, so the diff stays reviewable.

5. Fix the call sites that already have bugs. Search your issue tracker for “timezone”, “DST” and “off by one day”. Those are the places where Temporal fixes something rather than just changing syntax, and they’re worth a close review.

6. Remove the dependency. When the grep from step 3 returns nothing, uninstall Moment (and moment-timezone) in one PR. Leave the polyfill in until Safari ships Temporal and your analytics show old browsers are gone.

What Temporal doesn’t cover

Temporal handles date arithmetic and time zones. Some jobs stay with Intl:

Need Use
“3 minutes ago” Intl.RelativeTimeFormat (helper above)
Localized dates toLocaleString() / Intl.DateTimeFormat
“2 hours, 5 minutes” duration.toLocaleString() (Intl.DurationFormat)
Parsing “next Tuesday” or 03/04/26 Your own parser or a library; from() accepts ISO 8601 / RFC 9557 only

✨ Thank you for reading and I hope you find it helpful. I sincerely request for your feedback in the comment’s section.