Replacing Moment.js With date-fns or Temporal

Almost every long-lived JavaScript application has this line in its analyzer output:

moment              72.4 kB  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
  β”œβ”€ locale/         41.1 kB  (127 locale files)
  └─ moment.js       31.3 kB
react-dom           41.2 kB  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
your-app-code       38.9 kB  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ

A date library outweighing the framework, and outweighing the entire application’s own code. Two thirds of it is locale data for languages the product does not support, and the application uses the library for four things: formatting a timestamp, parsing an ISO string, adding days, and comparing two dates.

This is the canonical case for the audit procedure in auditing and replacing heavy dependencies: high weight, high reach in the sense that the library is called everywhere, but a tiny effective API surface. It is also the migration most likely to break silently, because date formatting fails by producing a wrong string rather than an error.

Root cause: a mutable object API defeats static analysis

Monolithic date libraries expose a single factory that returns an object carrying every method: formatting, parsing, arithmetic, durations, relative time, calendar helpers. Plugins and locales register themselves onto that object by mutation at import time.

That design is fundamentally opaque to a bundler. Tree-shaking, as described in advanced tree-shaking and dependency optimization, relies on a static export graph: the bundler must be able to prove that a binding is never read. When every capability hangs off one runtime object, and locale files mutate that object as a side effect, nothing is provably unreachable. The library ships whole, and the sideEffects techniques covered in configuring sideEffects for optimal tree-shaking cannot help, because the side effects are real.

Monolithic Object Versus Modular Functions On the left, one runtime object carries formatting, parsing, arithmetic, durations and every locale, so nothing can be dropped. On the right, three imported functions form the entire reachable graph. Monolithic β€” 72 KB ships one runtime object format βœ“ used parse βœ“ used durations relative time 127 locale files, registered by mutation 41 KB β€” none provably unreachable Modular β€” 9 KB ships three imported functions format parseISO addDays two locale modules, imported explicitly everything else is never referenced The bundler can only remove what it can prove nothing reaches

Step 1: isolate before you replace

Never migrate at the call sites. Introduce a wrapper module first, still implemented with the old library, as a pure refactor with no behavioural change. This is reviewable, revertible, and it turns the migration itself into a single-file diff.

// lib/dates.js β€” step one: the wrapper, still on the old implementation
import moment from 'moment';

export const formatDate = (v, p = 'YYYY-MM-DD') => moment(v).format(p);
export const parseDate = (s) => moment(s).toDate();
export const addDays = (v, n) => moment(v).add(n, 'days').toDate();
export const isBefore = (a, b) => moment(a).isBefore(b);

Once every call site imports from lib/dates.js, run the audit again. The wrapper’s export list is your effective API surface β€” usually four to eight functions where the library offered two hundred.

Step 2: choose the replacement by workload

The choice is not about which library is smaller in the abstract; it is about which one matches what your code actually does.

Pick the Replacement From the Workload Formatting-dominant code maps onto a modular formatting library; arithmetic and time-zone-dominant code maps onto the platform date API. Which half of the workload dominates? Formatting and parsing display strings, form input, comparison for display Arithmetic and time zones scheduling, recurrence, durations across DST boundaries Modular formatting library closest API match, smallest diff Platform date API distinct types, zero bundled bytes

Formatting-dominant code β€” dates rendered into human-readable strings, parsed from form input, compared for display β€” maps almost one-to-one onto a modular formatting library. The migration diff is small and the API shapes are familiar.

Arithmetic- and zone-dominant code β€” scheduling, recurring events, durations across daylight-saving boundaries β€” is better served by the platform’s own modern date API, which models instants, plain dates, and zoned date-times as distinct types instead of overloading one mutable object. Where it is available natively it ships zero bytes.

// lib/dates.js β€” step two: modular formatting library
// Only the referenced functions and the explicitly imported locale ship.
import { format, parseISO, addDays as addDaysFn, isBefore as isBeforeFn } from 'date-fns';
import { enGB } from 'date-fns/locale';

// NOTE: token vocabularies differ between libraries. 'YYYY-MM-DD' in the old
// library is 'yyyy-MM-dd' here; 'DD' means day-of-year in some vocabularies.
export const formatDate = (v, p = 'yyyy-MM-dd') =>
  format(typeof v === 'string' ? parseISO(v) : v, p, { locale: enGB });

export const parseDate = (s) => parseISO(s);
export const addDays = (v, n) => addDaysFn(typeof v === 'string' ? parseISO(v) : v, n);
export const isBefore = (a, b) => isBeforeFn(a, b);
// lib/dates.js β€” alternative: the platform date API for arithmetic-heavy code
// Zero bundled bytes where supported natively; a polyfill only for older targets.
export const addDays = (v, n) =>
  Temporal.PlainDate.from(v).add({ days: n }).toString();

export const formatDate = (v, locale = 'en-GB') =>
  new Intl.DateTimeFormat(locale, { dateStyle: 'medium' })
    .format(new Date(Temporal.Instant.from(v).epochMilliseconds));

export const isBefore = (a, b) =>
  Temporal.Instant.compare(Temporal.Instant.from(a), Temporal.Instant.from(b)) < 0;

Step 3: prove parity before deleting anything

Format tokens are the trap. Every library has its own vocabulary, and mismatched tokens produce a plausible-looking wrong string rather than an error. A parity harness catches this cheaply.

// scripts/date-parity.mjs β€” diff old and new implementations over a fixed corpus
import * as legacy from '../lib/dates.legacy.js';
import * as next from '../lib/dates.js';

const stamps = ['2026-01-01T00:00:00Z', '2026-02-29T12:30:00Z', '2026-06-15T23:59:59Z',
                '2026-10-25T01:30:00Z', '2026-12-31T22:00:00Z'];   // includes a DST boundary
const patterns = [undefined, 'yyyy-MM-dd', 'd MMM yyyy', 'HH:mm'];

let mismatches = 0;
for (const stamp of stamps) {
  for (const pattern of patterns) {
    const a = legacy.formatDate(stamp, pattern);
    const b = next.formatDate(stamp, pattern);
    if (a !== b) { mismatches++; console.log(`MISMATCH ${stamp} ${pattern}: ${a} vs ${b}`); }
  }
}
process.exit(mismatches ? 1 : 0);

Include a daylight-saving transition, a leap day, and a year boundary in the corpus. Those three cases surface the overwhelming majority of real migration defects.

Migration Sequence Call sites are first routed through a wrapper still using the old library; the wrapper implementation is then swapped and validated by a parity harness before the old dependency is removed. 1. Route call sites through one wrapper 2. Swap inside one file changes 3. Parity harness diff both outputs 4. Remove old dependency mismatch β†’ fix the wrapper, not the call sites The old dependency is removed last, after parity is proven β€” never first

Step-by-step verification

  1. Confirm the old package is at zero bytes. Re-run the byte attribution and check that the replaced library no longer appears at all β€” not merely that it got smaller.

  2. Confirm the replacement did not arrive whole. The new library should contribute only the functions you imported. If it contributes its full build, the import is hitting a barrel or a CommonJS entry point.

  3. Run the parity harness in CI. Keep it running for at least one release cycle after the migration, so a later change to the wrapper cannot silently alter output.

  4. Check every locale you ship. Format one timestamp per supported locale and compare against the pre-migration output. Locale-specific defects do not appear in the default locale.

  5. Check server and client agreement. Under server rendering, a formatting difference between environments produces a hydration mismatch rather than a wrong string, which is a different and noisier failure.

  6. Add the guardrail. A lint rule banning the old package by name, plus a size budget on the affected chunk, following enforcing performance budgets in CI.

Edge cases and gotchas

Time zone handling is not a formatting concern. A modular formatting library usually needs a companion package for zone conversion, and that package carries the zone database β€” which can be larger than the library you removed. If zones matter, prefer the platform API and its built-in formatter.

Mutation semantics. The old library’s operations often mutate in place, and code frequently relied on that without realising it. The replacements are immutable, so a chained call that previously modified a shared value now silently returns a new one and leaves the original unchanged.

Relative time strings. β€œ3 days ago” formatting is a separate capability, and the platform provides it natively. Reaching for another dependency here undoes part of the saving.

Transitive usage. A third-party component may import the old library itself, keeping it in the bundle after you have removed every call site of your own. The duplicate-detection workflow in finding duplicate dependencies in a bundle identifies which package is still pulling it in.

FAQ

Why does a date library ship 72 KB when I only format two dates?

Because a monolithic date library exposes one object with every capability attached to it β€” parsing, formatting, arithmetic, durations, relative time β€” and every locale is registered against that same object. There is no static import boundary the bundler can use to prove any of it unused, so the whole surface plus its locale tables ships regardless of how little you call. A modular library inverts this: each operation is a separate module, so unused ones are never reached.

Should I migrate to a modular library or to the platform’s own date API?

It depends on which side of the workload dominates. If most of your code formats and parses human-readable strings, a modular formatting library gives the closest API match and the smallest migration diff. If most of your code does calendar arithmetic, time zone conversion, and duration handling, the platform’s newer date API expresses that far more precisely and ships zero bytes where it is supported natively. Many codebases end up using the platform API for arithmetic and the platform’s built-in formatter for display.

How do I catch formatting differences that do not throw?

Run both implementations side by side over a fixed corpus of inputs and diff the resulting strings. Format tokens differ between libraries in ways that silently produce wrong output rather than errors β€” a token that means day-of-year in one library means day-of-month in another, and both return a plausible string. A parity harness over a few hundred timestamps across your supported locales catches this in minutes; code review does not catch it at all.