Intl.DateTimeFormat vs moment.js Performance Comparison

A cached Intl.DateTimeFormat instance formats dates roughly 5–10× faster than moment().format() and adds 0KB to your bundle — but an uncached Intl formatter recreated per call is slower than moment, so the win lives entirely in the caching pattern. This is a focused recipe under Mastering Intl.DateTimeFormat Options.

Why This Comparison Is Tricky

The naive benchmark lies in both directions. If you write new Intl.DateTimeFormat(...).format(date) in the loop you are measuring, you measure ICU locale-data parsing and rule compilation on every iteration — and Intl "loses" to moment by a wide margin. If instead you hoist the constructor outside the loop, you measure pure formatting in native C++ bindings, and Intl wins by an order of magnitude. The same library appears 35× slower or 7× faster depending purely on where the constructor lives. Any comparison that does not separate construction from formatting is meaningless.

The second subtlety is timezone behaviour: base moment (not moment-timezone) silently uses the host offset, so a benchmark run on a UTC CI box hides the correctness gap that bites in a browser. Pin an explicit zone in both libraries before comparing.

The timeline below shows the two execution paths — construct-then-format-many versus construct-per-format — so it is obvious where the cost accumulates.

Cached versus uncached Intl.DateTimeFormat execution paths Top track: one expensive construct step followed by many cheap format steps. Bottom track: a repeated expensive construct plus cheap format pair on every iteration, accumulating far more total cost. Where the cost lives Cached construct once · costly format format format … cheap, reused time → Uncached construct+fmt construct+fmt construct+fmt construct+fmt … cost repeats Same API — caching decides whether Intl beats moment.js

Minimal Working Benchmark

The shortest honest comparison hoists the cached formatter and isolates the uncached path so the construction cost is attributed correctly.

import { performance } from 'perf_hooks';
import moment from 'moment';

const DATE = new Date('2024-03-15T14:30:00Z');

// Built ONCE outside the loop — the production pattern.
const cached = new Intl.DateTimeFormat('en-US', {
  year: 'numeric', month: 'short', day: '2-digit',
  hour: '2-digit', minute: '2-digit', timeZone: 'UTC',
});

const run = (label: string, fn: () => string, n = 1_000_000) => {
  for (let i = 0; i < 10_000; i++) fn();        // warm-up triggers tiered JIT compilation
  const t = performance.now();
  for (let i = 0; i < n; i++) fn();
  console.log(label, (performance.now() - t).toFixed(0) + 'ms');
};

run('cached Intl', () => cached.format(DATE));   // ~120ms
run('uncached Intl', () =>                        // ~4200ms — re-parses CLDR per call
  new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' }).format(DATE));
run('moment.js', () => moment(DATE).utc().format('MMM DD, YYYY HH:mm')); // ~850ms

Representative Node.js 20 numbers (1M iterations): cached Intl ~120ms, moment ~850ms, uncached Intl ~4,200ms. The ordering — cached Intl ≪ moment ≪ uncached Intl — is stable across machines even when absolute numbers shift.

Full Production Version

In real code you want a validated, cached formatter that pins the zone and degrades safely, so the fast path is also the correct path.

type FmtOpts = Intl.DateTimeFormatOptions;
const cache = new Map<string, Intl.DateTimeFormat>();

function getFormatter(locale: string, opts: FmtOpts): Intl.DateTimeFormat {
  // Key includes locale + every option so distinct configs never collide.
  const key = locale + '|' + JSON.stringify(opts);
  let fmt = cache.get(key);
  if (!fmt) {
    fmt = new Intl.DateTimeFormat(locale, opts); // compiled once, reused thereafter
    cache.set(key, fmt);
  }
  return fmt;
}

export function formatTimestamp(
  ms: number,
  timeZone = 'UTC',
  locale = 'en-US',
): string {
  let zone = timeZone;
  try {
    new Intl.DateTimeFormat(locale, { timeZone: zone }).format(0); // probe the IANA id
  } catch {
    zone = 'UTC'; // unsupported zone / stale tzdata — fall back rather than throw
  }
  return getFormatter(locale, {
    year: 'numeric', month: 'short', day: '2-digit',
    hour: '2-digit', minute: '2-digit',
    timeZone: zone,
    timeZoneName: 'shortOffset', // surface DST/STD offset so output is unambiguous
  }).format(new Date(ms));
}

// DST fall-back in New York: 01:30 occurs twice, but a UTC instant resolves to one string.
console.log(formatTimestamp(1699165800000, 'America/New_York'));
// e.g. 'Nov 05, 2023, 01:30 AM GMT-4'

For DST-safe arithmetic (adding days/months across a transition) Intl is the wrong tool — move that to Temporal.ZonedDateTime.add(), covered in date arithmetic without mutations.

Verification Snippet

import assert from 'node:assert';

// Caching must not change output — same string as a fresh formatter.
const opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeZone: 'UTC' };
const a = getFormatter('en-US', opts).format(new Date('2024-03-15T00:00:00Z'));
const b = new Intl.DateTimeFormat('en-US', opts).format(new Date('2024-03-15T00:00:00Z'));
assert.strictEqual(a, b); // identical output, just faster

// The two fall-back instants render the same local time, different offsets.
const f = new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/New_York', hour: '2-digit', minute: '2-digit', timeZoneName: 'shortOffset',
});
assert.strictEqual(f.format(new Date('2023-11-05T05:30:00Z')), '01:30 GMT-4');
assert.strictEqual(f.format(new Date('2023-11-05T06:30:00Z')), '01:30 GMT-5');
console.log('all assertions passed');

Common Pitfalls

Frequently Asked Questions

Is Intl.DateTimeFormat actually faster than moment.js?

Yes — but only when the formatter is cached. A reused instance runs in native V8/SpiderMonkey C++ bindings and is roughly 5–10× faster than moment().format() in tight loops. Recreating the formatter on every call is the opposite: ICU initialization makes it slower than moment, which is why so many ad-hoc benchmarks reach the wrong conclusion.

Does Intl support every moment.js format token?

No. moment uses token strings like MMM Do, YYYY; Intl uses a declarative options object. There is no 1:1 mapping, so complex layouts require composing options or reconstructing output from formatToParts(). Map each moment token to its Intl equivalent systematically during migration.

How do I migrate a large codebase off moment.js?

Incrementally. Use a static-analysis tool to locate moment imports, replace formatting calls with cached Intl formatters behind a feature flag, monitor latency and errors, then move date arithmetic to Temporal before deleting the dependency.