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.
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
-
Benchmarking the uncached path and concluding
Intlis slow.dates.map(d => new Intl.DateTimeFormat('en-US').format(d)); // wrong: re-parses CLDR per row const fmt = new Intl.DateTimeFormat('en-US'); // right: hoist once dates.map(d => fmt.format(d)); // reuse the compiled instance -
Assuming
momentis tree-shakable. It mutates global locale state at import, so bundlers cannot drop locales — a minimal SPA inherits ~300KB uncompressed.Intlis a host feature and adds 0KB. -
Relying on implicit offsets. Base
momentuses the host offset;Intlrequires an explicit IANAtimeZone. Always pass the zone (and usemoment-timezoneif you must stay on moment). -
Using
Date.prototype.toLocaleString()in hot paths. It builds a throwaway formatter per call; cache anIntl.DateTimeFormatinstead.
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.
Related
- Mastering Intl.DateTimeFormat Options — the parent guide to the options object.
- Intl API & Legacy Date Patterns — the section overview.
- Date arithmetic without mutations — where to move moment's arithmetic.
- Legacy date methods vs modern alternatives — the broader case for dropping legacy date code.