Migrating From Legacy Date Libraries in JavaScript

How to move a production codebase off Moment.js onto modern, standards-based date handling without a risky big-bang rewrite. Part of Intl API & Legacy Date Patterns.

Moment.js shaped a decade of JavaScript date handling, but it now actively recommends against itself for new projects. The two structural problems are the same ones that bite teams in production: mutability and bundle size. A moment() object can be mutated in place by any function that receives it, so start.add(1, 'day') silently changes the original object that some other component is still holding β€” the source of countless "the date moved by itself" bugs. And because Moment bundles every locale and timezone rule into a monolithic, non-tree-shakable package, it adds roughly 230–290KB minified (over 70KB even with moment-timezone data trimmed). Modern alternatives β€” the native Intl APIs, the immutable Temporal API, and the tree-shakable date-fns β€” fix both problems, but the migration path matters as much as the destination.

What actually breaks during a migration

The danger is not rewriting one moment().format() call β€” it is the shared assumptions baked into a Moment codebase. Three failure modes dominate. First, silent mutation regressions: code that relied on Moment mutating an object in place breaks subtly when you swap in an immutable type that returns a new value instead. Second, parser leniency loss: Moment accepts almost any string, so years of data passed through moment(someString) without anyone validating the format; strict parsers reject those inputs and surface latent bad data. Third, timezone semantics drift: moment-timezone and Temporal both use the IANA database, but Moment's .add() mixes calendar and absolute arithmetic in ways that differ from Temporal's explicit days-vs-hours distinction, so DST-crossing math can shift by an hour after migration.

Model mismatch, not syntaxA rename is not enoughModel mismatch, not syntaxmutable moment chainsimmutable Temporal valueswrap at the boundarymigrate logic inwardMoment mutates and parses loosely; Temporal is immutable and strict β€” behaviour shifts.

Choosing the destination

This diagram shows the recommended flow: keep Moment behind an adapter while you route new and migrated code to the right modern target.

Moment to modern date stack migration pathMoment.js feeds an adapter layer, which routes calendar arithmetic to Temporal, formatting to Intl, and lightweight utilities to date-fns, with a decision note for each target.Moment.jslegacy callsitesAdapter layerone boundary moduleDate in / outTemporalcalendar arithmeticIntlformatting + i18ndate-fnslight utils on Datemigrate leaf modules firstdelete adapter last

The short version: reach for Temporal when you need correct calendar arithmetic, DST-aware addition, or non-Gregorian calendars; reach for Intl when you only need to format or compare; and reach for date-fns when you want small composable helpers and are comfortable staying on the native Date. Most real apps end up using two of the three. The detailed trade-off is in date-fns vs Temporal: which to choose.

Moment β†’ modern equivalents

The table maps the Moment calls that appear most often in real codebases. T is a Temporal import; dfns is date-fns.

Moment call Temporal / Intl equivalent date-fns equivalent Notes
moment() Temporal.Now.zonedDateTimeISO() new Date() Temporal carries the IANA zone explicitly
moment(iso) Temporal.Instant.from(iso) parseISO(iso) Temporal/parseISO are strict; Moment was lenient
moment(ms) Temporal.Instant.fromEpochMilliseconds(ms) new Date(ms) epoch is ms in JS, often seconds on backends
m.add(1, 'day') zdt.add({ days: 1 }) addDays(d, 1) Temporal keeps wall-clock across DST
m.add(2, 'hours') zdt.add({ hours: 2 }) addHours(d, 2) Temporal adds absolute time across DST
m.subtract(3, 'months') zdt.subtract({ months: 3 }) subMonths(d, 3) Temporal clamps month-end with overflow
m.format('YYYY-MM-DD') zdt.toPlainDate().toString() format(d, 'yyyy-MM-dd') Temporal toString() is ISO 8601
m.format('LLL') (localized) new Intl.DateTimeFormat(locale, opts).format(d) format(d, 'PPpp', { locale }) cache the Intl formatter
m.tz('America/New_York') instant.toZonedDateTimeISO('America/New_York') formatInTimeZone(d, tz, fmt) (date-fns-tz) both use IANA DB
m.diff(other, 'days') zdt.since(other, { largestUnit: 'days' }) differenceInDays(d, other) Temporal returns a Duration
m.isBefore(other) Temporal.ZonedDateTime.compare(a, b) < 0 isBefore(a, b) comparison returns -1/0/1
m.startOf('day') zdt.startOfDay() startOfDay(d) Temporal's is DST-correct
m.utc() instant / zdt.withTimeZone('UTC') use Date UTC accessors Instant is inherently absolute

A step-by-step before/after for the most common calls lives in the Moment.js to Temporal migration guide.

Moment β†’ modernCommon calls and their replacementsMoment β†’ modernmoment(s)Temporal.*.from(s) / Instantm.add(1,'day')zdt.add({ days: 1 })m.tz('NY')toZonedDateTimeISO('NY')m.format('LLL')Intl.DateTimeFormat

Approach A: the quick like-for-like swap (and why it disappoints)

The instinct is a mechanical find-and-replace: every moment(x).format(f) becomes a date-fns format(parseISO(x), f2). This works for the simplest formatting calls, but it preserves Moment's worst trait β€” it keeps date logic scattered across the codebase. You replace one library dependency with another while leaving the architectural problem (no single place that owns date semantics) untouched. Worse, format tokens differ: Moment's YYYY-MM-DD is date-fns's yyyy-MM-dd, and a silent token mismatch produces wrong output rather than a crash. Use the mechanical swap only for genuine leaf-level formatting, and only with the mapping table open beside you.

Like-for-like swapRenaming calls keeps the old mental modelLegacyModernreplace moment→dayjssame mutable APIzones still bolt-onlimited upsideadopt Temporal typesimmutable + zonesstdlib-trackreal modernization

Approach B: wrap at the boundary, migrate inward

The reliable strategy treats Moment as a dependency to be quarantined, not a set of calls to be rewritten in place.

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

// The ONE place legacy Moment objects are allowed to exist.
// Everything outside this module speaks Temporal or native Date.
export function momentToZdt(m: moment.Moment, timeZone: string): Temporal.ZonedDateTime {
  // m.valueOf() is epoch milliseconds β€” the lossless interop currency.
  return Temporal.Instant
    .fromEpochMilliseconds(m.valueOf())
    .toZonedDateTimeISO(timeZone); // attach an explicit IANA zone, never a raw offset
}

export function zdtToDate(zdt: Temporal.ZonedDateTime): Date {
  // Date and Instant share the same absolute epoch-ms representation.
  return new Date(zdt.epochMilliseconds);
}

export function dateToZdt(d: Date, timeZone: string): Temporal.ZonedDateTime {
  return Temporal.Instant
    .fromEpochMilliseconds(d.getTime())
    .toZonedDateTimeISO(timeZone);
}

With this adapter in place, the migration order is: (1) wrap the I/O edges β€” API responses, form inputs, database rows β€” so values cross the boundary as ISO strings or epoch milliseconds; (2) migrate leaf modules first (pure helpers, formatters, validators that have no internal callers); (3) work up the call tree toward the boundary; (4) delete the adapter and the Moment dependency only once nothing else imports them. Migrating leaves first means each change is small, locally testable, and never leaves the tree in a half-typed state.

Boundary-first migrationWrap parse/format, convert inner logic to TemporalBoundary-first migrationcall sitesfacadeTemporal coredrop moment

Production implementation: a typed facade

The pragmatic way to migrate off a legacy date library is a typed facade: a small internal module that exposes the date operations your app needs, implemented today over the legacy library and tomorrow over Temporal and Intl, with the rest of the codebase depending only on the facade. This inverts the migration from a risky big-bang rewrite into an incremental one β€” you swap the facade's implementation operation by operation, behind a stable interface, and the app keeps working throughout. The facade is also where you make the improvements the migration is really about: explicit zones, immutable values, and the right type per value.

Because a legacy library like Moment conflates instants, wall-clock times, and civil dates in one type, the facade's interface is the place to split them β€” separate methods that return an Instant, a ZonedDateTime, or a PlainDate as appropriate β€” so callers start expressing which kind of time they mean. Keep canonical ISO strings as the stored representation so data written during the migration is library- and engine-independent, which means a half-migrated system never persists anything tied to the old library. When the last operation is ported, the legacy dependency (and eventually the Temporal polyfill) drops out with a single change to the facade.

A single facade module gives the rest of the app a stable, immutable-only API and lets you change the backing implementation (Moment today, Temporal tomorrow) without touching callers.

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

export interface DateFacade {
  now(timeZone: string): Temporal.ZonedDateTime;
  parse(iso: string, timeZone: string): Temporal.ZonedDateTime;
  format(zdt: Temporal.ZonedDateTime, locale: string): string;
}

// Cache Intl formatters by locale β€” constructing one per call is the
// single biggest formatting performance mistake (see the perf comparison).
const fmtCache = new Map<string, Intl.DateTimeFormat>();

export const dates: DateFacade = {
  now(timeZone) {
    return Temporal.Now.zonedDateTimeISO(timeZone);
  },
  parse(iso, timeZone) {
    // Throw early on malformed input rather than silently coercing,
    // surfacing the bad data Moment's lenient parser used to hide.
    if (!iso) throw new TypeError('parse() requires a non-empty ISO string');
    return Temporal.Instant.from(iso).toZonedDateTimeISO(timeZone);
  },
  format(zdt, locale) {
    let f = fmtCache.get(locale);
    if (!f) {
      f = new Intl.DateTimeFormat(locale, {
        dateStyle: 'medium',
        timeStyle: 'short',
        timeZone: zdt.timeZoneId, // explicit zone avoids SSR host-zone drift
      });
      fmtCache.set(locale, f);
    }
    return f.format(new Date(zdt.epochMilliseconds));
  },
};

On the server or in serverless functions, never rely on the host timezone β€” pass the IANA zone explicitly into now() and format(). A Lambda runs in UTC, your laptop does not, and that mismatch is exactly the class of bug Moment migrations are meant to eliminate. For why this matters in detail, see legacy date methods vs modern alternatives.

A typed facadeOne module owns parsing/formatting; the app uses typesA typed facadefacade APITemporalinternallytyped valuesout

Edge cases

The mutation edge is the one that silently breaks literal migrations: Moment objects mutate in place (m.add(1, 'day') changes m), while Temporal values are immutable and return new instances, so ported code that ignores the return value does nothing. Audit for discarded return values during the migration. The implicit-zone edge is next: moment() uses the host zone silently, the very source of UTC-server bugs, so each ported path is an opportunity to make the zone explicit with a named ZonedDateTime.

The type-mapping edge is subtler than a rename. One legacy call can map to different Temporal types depending on what the value actually is, so deciding Instant vs ZonedDateTime vs PlainDate per value is the substantive work β€” a mechanical one-to-one mapping reproduces the old type confusion. Formatting is a separate concern from arithmetic: port arithmetic to Temporal and formatting to Intl as distinct steps, since conflating them makes the diff harder to review. Finally, verify migrated output rather than assuming equivalence β€” the legacy library and Intl/Temporal follow different formatting and overflow conventions, so some results legitimately change and should be checked, not assumed.

DST gap during spring-forward

When you migrate moment.tz() arithmetic to Temporal, the nonexistent local time on a spring-forward day (e.g. 2024-03-10T02:30 in America/New_York) is handled by Temporal's disambiguation option, not silently coerced.

const zdt = Temporal.ZonedDateTime.from({
  year: 2024, month: 3, day: 10, hour: 2, minute: 30,
  timeZone: 'America/New_York',
}, { disambiguation: 'later' }); // 'later' shifts the gap forward to 03:30; 'reject' would throw

Fall-back overlap

In autumn an hour repeats. Moment quietly picked one; Temporal forces the choice via 'earlier' or 'later', eliminating "the timestamp jumped backward" tickets.

Lenient-parse data rot

Strings like "2024-13-45" or "next thursday" that Moment once swallowed now throw at the boundary. Catch and log them during the migration window so you can fix the upstream data source rather than re-adding lenient parsing.

Month-end rollover

moment().add(1, 'month') on January 31 produced February 28/29 via clamping. Temporal makes the policy explicit: zdt.add({ months: 1 }, { overflow: 'constrain' }) clamps, while 'reject' throws β€” pick the one your business rule actually wants.

Migration edge casesWhere behaviour silently differsMigration edge casesLax parsingmoment accepts junkTemporal rejectsMutationin-place vs copyreassignDST addmoment vs Temporalwall-clock rules

Gotchas & common pitfalls

Migration pitfallsCarrying old habitsWrongRightexpect mutationTemporal returns copiescapture returned valuesimmutablebig-bang rewriteriskyboundary-first, incrementalsafe

Testing checklist

Scenario Input Expected
Epoch interop round-trip momentToZdt(m).epochMilliseconds m.valueOf() (lossless)
Spring-forward gap 02:30 on 2024-03-10 NY, 'later' resolves to 03:30 EDT
Fall-back overlap 01:30 on 2024-11-03 NY, 'earlier' first (EDT) instance
Month-end add Jan 31 + 1 month, 'constrain' Feb 28/29
Lenient string parse('not a date') throws RangeError
Format token parity Moment YYYY-MM-DD vs Temporal identical string

Run the suite across zones so host-zone assumptions surface:

# Run the same tests under several host zones to catch hidden local-time assumptions.
for TZ in UTC America/New_York Australia/Sydney Asia/Kolkata; do
  TZ=$TZ npx jest dates
done

Migration test matrixParity after each pattern swapAssertions that prove the edge caseparse paritysame instantadd across DSTwall-clock heldformat paritysame stringcompare paritysame order

Frequently Asked Questions

Is Moment.js deprecated?

Moment is not removed from npm, but it is in maintenance mode: its own documentation recommends against using it in new projects and it receives no new features. The practical reasons to leave are its mutable objects and its large, non-tree-shakable bundle. Migrate at a steady pace rather than in a panic β€” existing apps keep working.

Do I have to migrate everything at once?

No, and you should not. Wrap Moment behind a single adapter module, migrate leaf modules to Temporal, Intl, or date-fns first, then work up the call tree. Delete the adapter and the Moment dependency only after nothing imports them. Each step is small and independently testable.

How do I pass dates between Moment and Temporal during the transition?

Use epoch milliseconds as the interchange currency: m.valueOf() gives ms, Temporal.Instant.fromEpochMilliseconds(ms) reads it back, and new Date(zdt.epochMilliseconds) bridges to native Date. Epoch ms is absolute and lossless, so it never introduces a timezone bug at the boundary.

Should I move to date-fns or Temporal?

Use Temporal when you need DST-aware arithmetic, multi-calendar support, or immutable types and can ship the polyfill. Use date-fns when you want small tree-shakable helpers and are content to keep working with the native Date. Many codebases use Intl for formatting plus one of the two for arithmetic.

How do I migrate off a legacy date library without a big-bang rewrite?

Use a typed facade: a small internal module exposing the date operations your app needs, implemented over the legacy library today and swapped to Temporal and Intl operation by operation. The rest of the codebase depends only on the facade, so the migration is incremental and the app keeps working. Make the facade the place to introduce explicit zones, immutable values, and distinct types per value, and keep canonical ISO strings as stored data.

Why can't I map each Moment method to one Temporal method?

Because Moment conflates instants, wall-clock times, and civil dates in a single type, so one Moment call can map to different Temporal types depending on what the value actually is. Deciding Instant versus ZonedDateTime versus PlainDate per value is the real work of the migration; a mechanical one-to-one rename reproduces the old type confusion. Also handle mutation (Moment mutates, Temporal returns new values) and make the implicit local zone explicit.

Should I migrate formatting and arithmetic together?

No β€” treat them as separate concerns. Port date arithmetic to Temporal and formatting to Intl as distinct steps, since conflating them makes the change harder to review and reason about. Verify migrated output rather than assuming equivalence, because the legacy library and Intl/Temporal follow different formatting and overflow conventions, so some results legitimately differ and should be checked.