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.
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.
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.
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.
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.
Production implementation: a typed 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.
Edge cases
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.
Gotchas & common pitfalls
- Format-token mismatch: Moment's
YYYY/DDare date-fns'syyyy/dd. Fix: keep the mapping table open and add a snapshot test on formatted output. - Mutation-dependent code: code that relied on
m.add()mutating a shared object breaks against immutable types. Fix: search for callers that ignore the return value of an add/subtract call — those are the latent bugs. - Offset vs zone: migrating to a stored numeric offset (
-05:00) instead of an IANA id (America/New_York) reintroduces DST bugs. Fix: store the IANA identifier plus UTC, never a bare offset. - Per-call formatter construction: swapping
m.format()for a freshnew Intl.DateTimeFormat()each call is slow. Fix: cache formatters by locale+options key. - Half-migrated trees: migrating a parent before its children leaves both types flowing through one function. Fix: migrate leaf modules first, upward toward the boundary.
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
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.