Skip to main content

Temporal API: Replace Moment.js and date-fns

·PkgPulse Team

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

CapabilityMoment.jsdate-fns v4Temporal
Bundle size67KB (full)~2KB/fn tree-shaken0KB (native) / ~20KB polyfill
Immutability❌ (mutable)
Time zone supportVia moment-timezone (+60KB)Via @date-fns/tzNative
Calendar systems✅ (ISO, Japanese, Hebrew, etc.)
DST handling❌ (bugs)Partial✅ correct
Duration typeTemporal.Duration
Type of dateDate wrapperDate functionsNew types
TypeScriptPoorExcellentExcellent
Browser support (native)AnyAnyChrome 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

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.