Temporal API: Replace Moment.js and date-fns
The Temporal API reached Stage 4 at the March 2026 TC39 meeting. It's native in Chrome 144+, Firefox 139+, and available behind a flag in Node.js v24. The 30-year wait for a correct JavaScript date library is over — and the API was worth the wait.
Moment.js is still downloaded 13 million times a week. date-fns gets 20 million. Day.js — the lightweight Moment.js successor — gets 40 million. Most of that code is doing things Temporal now handles natively, without a 67KB library or a functional import chain. This article covers what Temporal replaces, what it doesn't, and how to migrate.
TL;DR
Temporal replaces Moment.js and date-fns for most use cases — date arithmetic, time zones, duration calculation, calendar math. It does NOT replace relative time formatting ("2 hours ago") or locale-aware display strings. Use temporal-polyfill (~20KB) for new projects today; @js-temporal/polyfill (~44KB) for the full reference implementation. Node.js native Temporal (no flag) is not yet available — use a polyfill for Node.js services.
Key Takeaways
- Temporal.PlainDate / PlainDateTime: immutable dates without time zones — use for most business logic
- Temporal.ZonedDateTime: the correct type for calendar events, scheduling, user-facing times
- Temporal.Instant: a point in time (like
Date.now()but correct) - Temporal.Duration: date/time arithmetic results —
d1.until(d2)returns a Duration - Immutable by default: all methods return new objects — no
.clone()needed temporal-polyfill: ~20KB gzipped, preferred for production (smaller, near-perfect spec compliance)@js-temporal/polyfill: ~44KB gzipped, full reference implementation, passes full TC39 test suite- Migration path: Moment → Temporal is straightforward; date-fns → Temporal is mostly 1:1 with different syntax
At a Glance
| Capability | Moment.js | date-fns v4 | Temporal |
|---|---|---|---|
| Bundle size | 67KB (full) | ~2KB/fn tree-shaken | 0KB (native) / ~20KB polyfill |
| Immutability | ❌ (mutable) | ✅ | ✅ |
| Time zone support | Via moment-timezone (+60KB) | Via @date-fns/tz | Native |
| Calendar systems | ❌ | ❌ | ✅ (ISO, Japanese, Hebrew, etc.) |
| DST handling | ❌ (bugs) | Partial | ✅ correct |
| Duration type | ❌ | ❌ | ✅ Temporal.Duration |
| Type of date | Date wrapper | Date functions | New types |
| TypeScript | Poor | Excellent | Excellent |
| Browser support (native) | Any | Any | Chrome 144+, FF 139+ |
| Maintenance | ❌ (legacy mode) | ✅ active | ✅ TC39 spec |
The Problem with Date
Before diving into Temporal, it's worth understanding why two generations of date libraries exist. JavaScript's Date object was copied from Java in 10 days in 1995 and has been broken since:
// Month is 0-indexed — January is 0, December is 11
new Date(2026, 0, 1) // January 1, 2026
new Date(2026, 11, 31) // December 31, 2026 — not month 11!
// Date is mutable — this is a trap
const d = new Date('2026-01-01')
d.setMonth(d.getMonth() + 1) // mutates d in place
console.log(d) // 2026-02-01 — but you might not expect d changed
// DST math is broken
const dt = new Date('2026-11-01T01:30:00')
dt.setHours(dt.getHours() + 1)
// During a DST fallback, this can give you the wrong answer
// Parsing is implementation-specific
new Date('2026-3-1') // works in V8, may fail elsewhere
new Date('March 1, 2026') // works in browsers, inconsistent in Node
Moment.js fixed the API surface but not the underlying model (still uses Date internally, still mutable by default). date-fns is immutable but doesn't have its own date type — it operates on native Date objects and can't represent concepts like "duration" or "time-zone-aware datetime."
Temporal fixes the model.
The Temporal Type System
Temporal's key insight is that "date" and "time" are different concepts that need different types:
Temporal.PlainDate — just a date: 2026-03-16 (no time, no timezone)
Temporal.PlainTime — just a time: 14:30:00 (no date, no timezone)
Temporal.PlainDateTime — date + time: 2026-03-16T14:30:00 (no timezone)
Temporal.ZonedDateTime — date + time + timezone: 2026-03-16T14:30:00[America/New_York]
Temporal.Instant — exact point in time (epoch nanoseconds)
Temporal.Duration — a span of time: P1Y2M3DT4H5M6S
Temporal.PlainYearMonth — just year + month: 2026-03
Temporal.PlainMonthDay — just month + day: --03-16 (for recurring events)
Choosing the right type prevents entire classes of bugs:
// Bad: using Date for a birthdate (timezone corrupts it)
const birthday = new Date('1990-05-15') // stored as UTC, displayed in local timezone
// Good: PlainDate has no timezone — it's always May 15, 1990
const birthday = Temporal.PlainDate.from('1990-05-15')
// Bad: scheduling a meeting without timezone info
const meeting = new Date('2026-06-01T14:00:00') // what timezone??
// Good: ZonedDateTime is unambiguous
const meeting = Temporal.ZonedDateTime.from({
year: 2026, month: 6, day: 1, hour: 14,
timeZone: 'America/New_York'
})
Core Operations: Temporal vs Moment.js vs date-fns
Creating Dates
// Current date/time
// Moment
const now = moment()
// date-fns (no special constructor — uses native Date)
const now = new Date()
// Temporal
const now = Temporal.Now.plainDateTimeISO() // PlainDateTime
const today = Temporal.Now.plainDateISO() // PlainDate only
const instant = Temporal.Now.instant() // Exact point in time (UTC)
// From a string
// Moment
moment('2026-03-16')
moment('2026-03-16T14:30:00')
// date-fns
parseISO('2026-03-16')
parseISO('2026-03-16T14:30:00')
// Temporal
Temporal.PlainDate.from('2026-03-16')
Temporal.PlainDateTime.from('2026-03-16T14:30:00')
Temporal.ZonedDateTime.from('2026-03-16T14:30:00[America/New_York]')
Temporal.Instant.from('2026-03-16T14:30:00Z') // Z = UTC
// From parts
// Moment
moment({ year: 2026, month: 2, day: 16 }) // month is 0-indexed!
// date-fns
new Date(2026, 2, 16) // month is 0-indexed!
// Temporal — months are 1-indexed, like a human
Temporal.PlainDate.from({ year: 2026, month: 3, day: 16 })
Arithmetic
// Add 30 days
// Moment (mutates!)
moment().add(30, 'days') // modifies the moment object
// date-fns
addDays(new Date(), 30) // returns new Date
// Temporal (always returns new object)
Temporal.Now.plainDateISO().add({ days: 30 })
// Subtract 1 month
// Moment
moment('2026-03-31').subtract(1, 'month') // → 2026-02-28 (may not be what you want)
// date-fns
subMonths(new Date('2026-03-31'), 1) // → 2026-02-28
// Temporal — same result
Temporal.PlainDate.from('2026-03-31').subtract({ months: 1 }) // → 2026-02-28
// Add 1 year and 2 months and 15 days
// Moment
moment().add(1, 'year').add(2, 'months').add(15, 'days')
// date-fns
addDays(addMonths(addYears(new Date(), 1), 2), 15) // nested
// Temporal — single operation, correct calendar arithmetic
Temporal.Now.plainDateISO().add({ years: 1, months: 2, days: 15 })
Comparison
// Is date A before date B?
// Moment
moment('2026-01-01').isBefore(moment('2026-06-01')) // true
// date-fns
isBefore(new Date('2026-01-01'), new Date('2026-06-01')) // true
// Temporal
Temporal.PlainDate.compare(
Temporal.PlainDate.from('2026-01-01'),
Temporal.PlainDate.from('2026-06-01')
) < 0 // true
// Or use .since() for more info
const d1 = Temporal.PlainDate.from('2026-01-01')
const d2 = Temporal.PlainDate.from('2026-06-01')
d1.until(d2) // Temporal.Duration { months: 5, days: 0 }
Duration Between Dates
// How many days between two dates?
// Moment
moment('2026-06-01').diff(moment('2026-01-01'), 'days') // 151
// date-fns
differenceInDays(new Date('2026-06-01'), new Date('2026-01-01')) // 151
// Temporal — returns a Duration object, not just a number
const d1 = Temporal.PlainDate.from('2026-01-01')
const d2 = Temporal.PlainDate.from('2026-06-01')
const duration = d1.until(d2) // Temporal.Duration
duration.days // 0 (months are not converted to days by default)
d1.until(d2, { largestUnit: 'day' }).days // 151
// Or get a rich "1 year, 2 months, 3 days" breakdown
d1.until(d2, { largestUnit: 'year' })
// Temporal.Duration { months: 5, days: 0 }
Time Zones
This is where Temporal's advantage over Moment.js is most dramatic:
// Current time in Tokyo
// Moment (requires moment-timezone)
moment().tz('Asia/Tokyo').format('HH:mm')
// date-fns (requires @date-fns/tz)
format(toZonedTime(new Date(), 'Asia/Tokyo'), 'HH:mm', {
timeZone: 'Asia/Tokyo'
})
// Temporal (native, no extra package)
Temporal.Now.zonedDateTimeISO('Asia/Tokyo').toLocaleString('ja-JP', {
hour: '2-digit', minute: '2-digit'
})
// Convert between time zones
const nyMeeting = Temporal.ZonedDateTime.from({
year: 2026, month: 6, day: 1,
hour: 14, minute: 0,
timeZone: 'America/New_York'
})
const tokyoTime = nyMeeting.withTimeZone('Asia/Tokyo')
console.log(tokyoTime.hour) // 3 (next day, +14 offset)
Formatting
Temporal integrates with Intl.DateTimeFormat but doesn't have its own format syntax (no "YYYY-MM-DD" strings):
const date = Temporal.PlainDate.from('2026-03-16')
// Built-in .toLocaleString()
date.toLocaleString('en-US', { dateStyle: 'full' })
// "Monday, March 16, 2026"
date.toLocaleString('de-DE', { dateStyle: 'medium' })
// "16. März 2026"
// ISO string
date.toString() // "2026-03-16"
// For custom formats like "03/16/26", use template literals
const d = Temporal.PlainDate.from('2026-03-16')
`${String(d.month).padStart(2, '0')}/${String(d.day).padStart(2, '0')}/${String(d.year).slice(-2)}`
// "03/16/26"
If you need a custom format string like "YYYY-MM-DD HH:mm", Temporal doesn't have this built-in. For complex formatting, a small formatting utility or Intl.DateTimeFormat with options covers most cases.
Migrating from Moment.js
Moment.js is in legacy mode (no new features since 2020). The migration to Temporal is the recommended path:
Common Patterns
// 1. Current date
moment() → Temporal.Now.plainDateTimeISO()
// 2. Parse ISO string
moment('2026-03-16') → Temporal.PlainDate.from('2026-03-16')
// 3. Format
moment().format('YYYY-MM-DD') → Temporal.Now.plainDateISO().toString()
moment().format('MM/DD/YYYY') → // Use template literal or Intl
// 4. Add time
moment().add(7, 'days') → Temporal.Now.plainDateISO().add({ days: 7 })
// 5. Difference in days
moment(a).diff(b, 'days') → Temporal.PlainDate.from(a).until(
Temporal.PlainDate.from(b),
{ largestUnit: 'day' }
).days
// 6. Is before/after
moment(a).isBefore(b) → Temporal.PlainDate.compare(
Temporal.PlainDate.from(a),
Temporal.PlainDate.from(b)
) < 0
// 7. Start of month
moment().startOf('month') → Temporal.Now.plainDateISO().with({ day: 1 })
// 8. End of month
moment().endOf('month') → Temporal.Now.plainDateISO()
.add({ months: 1 })
.with({ day: 1 })
.subtract({ days: 1 })
What You Lose
Moment has a few features with no direct Temporal equivalent:
// Relative time ("2 hours ago", "in 3 days") — Temporal has no fromNow()
moment('2026-03-10').fromNow() // "6 days ago"
// For this, use Intl.RelativeTimeFormat (native) or a small library
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
rtf.format(-6, 'day') // "6 days ago"
Migrating from date-fns
date-fns and Temporal have more conceptual overlap since both are functional/immutable. The migration is mostly syntax:
// 1. Parse
parseISO('2026-03-16') → Temporal.PlainDate.from('2026-03-16')
// 2. Add
addDays(date, 7) → temporalDate.add({ days: 7 })
addMonths(date, 1) → temporalDate.add({ months: 1 })
addYears(date, 1) → temporalDate.add({ years: 1 })
// 3. Subtract
subDays(date, 7) → temporalDate.subtract({ days: 7 })
// 4. Difference
differenceInDays(a, b) → Temporal.PlainDate.from(b)
.until(a, { largestUnit: 'day' }).days
// 5. Format
format(date, 'yyyy-MM-dd') → temporalDate.toString()
format(date, 'MMMM d, yyyy') → temporalDate.toLocaleString('en-US', { dateStyle: 'long' })
// 6. Is valid
isValid(date) → try { Temporal.PlainDate.from(str); return true }
catch { return false }
// 7. Start/end of period
startOfMonth(date) → temporalDate.with({ day: 1 })
endOfMonth(date) → temporalDate.with({ day: temporalDate.daysInMonth })
startOfYear(date) → temporalDate.with({ month: 1, day: 1 })
What date-fns Still Does Better
// Complex format strings — still easier in date-fns
format(date, 'EEE, MMM d') // "Mon, Mar 16"
// Temporal: manual construction or Intl options
// Locale-aware formatting without Intl options
formatDistance(date, new Date()) // "3 days ago"
// Temporal: use Intl.RelativeTimeFormat directly
Using Temporal Today: The Polyfill
Two polyfills are available. For most production projects, temporal-polyfill is the better choice:
# Recommended: smaller, production-optimized
npm install temporal-polyfill
# Reference implementation: larger, full TC39 compliance guarantee
npm install @js-temporal/polyfill
// temporal-polyfill (~20KB gzip) — recommended
import { Temporal } from 'temporal-polyfill'
// @js-temporal/polyfill (~44KB gzip) — full reference implementation
import { Temporal, Intl, toTemporalInstant } from '@js-temporal/polyfill'
Date.prototype.toTemporalInstant = toTemporalInstant // legacy Date interop
temporal-polyfill (maintained by FullCalendar) is ~2x smaller and passes the overwhelming majority of the TC39 test suite. @js-temporal/polyfill is the official reference implementation from the proposal champions — use it if you need the absolute last edge case covered.
Once Chrome 144+ is your minimum baseline (likely 2027 for most apps), you can remove the polyfill and the bundle drops to 0.
// Future-proof code: write polyfill-compatible Temporal now
// When native support is baseline, just remove the polyfill import
When to Use What in 2026
Use Temporal (with polyfill) if:
- You're starting a new project and want to write forward-compatible code
- You have complex time zone requirements that need ZonedDateTime
- You need calendar arithmetic beyond Gregorian (ISO, Japanese, Hebrew)
- You want Duration as a first-class type for scheduling or countdown logic
Keep date-fns v4 if:
- You have an existing date-fns codebase — migration effort isn't worth it yet
- You need complex format strings (
format(date, 'EEE, MMM d, h:mm a')) - You're on Node.js services and native Temporal isn't production-ready yet (no unflagged support as of March 2026)
- Your team knows date-fns well and Temporal's type system adds learning overhead
Finally remove Moment.js:
- Moment is 67KB uncompressed, mutable, and in legacy mode
- If you're on Moment, migrate to either date-fns v4 or Temporal now
- There is no reason to use Moment in a new project in 2026
The Time Zone Case Study
Temporal's biggest win over everything else is time zones. Here's a common real-world scenario — a "next meeting" scheduler:
// Schedule a recurring meeting for every Monday at 9am Eastern
// that stays at 9am ET even across DST transitions
// Moment-timezone (broken for DST edge cases)
const next = moment.tz('America/New_York').day(1).hour(9).minute(0)
// DST bugs: this might give 8am or 10am after a DST transition
// Temporal (correct)
function nextMondayAt9amET() {
const now = Temporal.Now.zonedDateTimeISO('America/New_York')
// Find next Monday
const daysUntilMonday = (8 - now.dayOfWeek) % 7 || 7
const nextMonday = now.add({ days: daysUntilMonday })
// Set to 9am — Temporal handles DST correctly
return nextMonday.with({ hour: 9, minute: 0, second: 0 })
}
const meeting = nextMondayAt9amET()
console.log(meeting.toString())
// "2026-03-23T09:00:00-04:00[America/New_York]"
// Always 9am ET — even if DST changes between now and then
Browser and Node.js Support
Temporal native support (March 2026):
Chrome 144+ ✅ stable
Firefox 139+ ✅ stable
Safari ⚠️ partial (Technology Preview)
Edge 144+ ✅ stable
Node.js v24+ ⚠️ behind flag (--harmony-temporal) — NOT unflagged as of March 2026
Bun ❌ not yet
Deno ❌ not yet
Node.js Temporal tracking: github.com/nodejs/node#57891 (open, awaiting triage as of March 2026)
Production Node.js: use a polyfill, not the native flag.
Polyfill options:
temporal-polyfill: ✅ ~20KB gzip (recommended)
@js-temporal/polyfill: ✅ ~44KB gzip (reference implementation)
Both pass the TC39 test suite; temporal-polyfill is 2x smaller
Track Moment.js vs date-fns vs Temporal polyfill npm trends on PkgPulse.
Related: date-fns v4 vs Temporal vs Day.js · ECMAScript 2026 Features · Node.js Native TypeScript 2026
See the live comparison
View temporal api vs. momentjs on PkgPulse →