Moment.js to Temporal: A Migration Guide for 2026
Temporal is ES2026 and on by default in Node 26. Map Moment.js, date-fns and Day.js calls to Temporal, pick a polyfill for Safari, and migrate without a rewrite.
On this page 14 sections
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.

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:
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:
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/tzand 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:
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():
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:
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:
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:
{
"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:
// 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:
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:
InstantorZonedDateTime: store aDatefromnew 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 BSONDatefields, so this matters for expiring documents.PlainDate: store theYYYY-MM-DDstring or a SQLdatecolumn. 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:
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:
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:
// 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:
git grep -cE "from ['\"](moment|moment-timezone|date-fns|dayjs|luxon)" -- src \
| sort -t: -k2 -nr | head -204. 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 |
Related reading
- Node.js v26 Upgrade: Temporal by Default, Type Stripping. Breaking changes and a pre-upgrade scanner script.
- TypeScript 6.0 Breaking Changes & New Features Explained. The release that added Temporal types.
- From AMD/UMD to ESM: A No-Nonsense Migration Guide. Needed for the conditional polyfill loader.
- Guide to Node.js Timers: setTimeout, setImmediate & nextTick. The other half of time in Node: scheduling rather than dates.
- Structured Logging in Node.js with Pino. Where
Instanttimestamps end up.
✨ Thank you for reading and I hope you find it helpful. I sincerely request for your feedback in the comment’s section.