Date & Number Formatting Standards
When a price renders as 1,234.50 in Berlin or a “2 hours ago” timestamp jumps by an hour after a DST transition, the bug is almost always a missing timeZone option or a hand-rolled formatter bypassing Intl. This page is the implementation reference for locale-aware date, time, number, currency, and relative-time formatting using the ECMA-402 Intl APIs — Intl.DateTimeFormat, Intl.NumberFormat, and Intl.RelativeTimeFormat — including time zones, DST, currency display, formatter caching, and the CLDR data that backs all of it.
Formatting sits one layer below the strings themselves. The resolved locale arrives from negotiation; the formatter turns raw Date and number values into locale-correct text. Get it wrong and every market sees subtly broken output: wrong decimal marks, wrong currency symbol placement, off-by-one dates near midnight, or pluralized relative-time phrases that read like machine translation. Get it right once, centralize it, and every surface — server-rendered HTML, client hydration, static export — produces identical bytes.
Prerequisites
Concept and Specification
The behavior of every Intl constructor is defined by ECMA-402 (the ECMAScript Internationalization API Specification), which in turn defers to the Unicode CLDR (Common Locale Data Repository) for the actual locale patterns and to Unicode TR35 for the locale identifier and pattern syntax. Time zone handling is governed by the IANA Time Zone Database (TZDB), exposed through the timeZone option as identifiers like Europe/Berlin. Locale tags follow BCP 47 / RFC 5646, including Unicode extensions such as -u-ca- (calendar), -u-nu- (numbering system), and -u-hc- (hour cycle).
This formatting layer is one stage of Core i18n Architecture & Locale Negotiation: negotiation produces the locale, the fallback chain resolver guarantees a usable locale even when regional data is thin, and formatting renders values within it. The critical mental model is that Intl is declarative: you describe the desired output with options ({ style: 'currency', currency: 'EUR' }), and CLDR decides symbol placement, grouping, and digit shaping. You never assemble the string yourself — doing so is the root cause of nearly every formatting defect.
12,34,567) — which is exactly why you never assemble the string yourself.ECMA-402 also exposes resolvedOptions() on every formatter, which reports the locale and options the engine actually selected after negotiation and fallback. Treat it as your debugging entry point: if resolvedOptions().timeZone says UTC when you expected America/New_York, you found the bug before a user did.
Step-by-Step Implementation
1. Centralize formatter construction
Create one module that all formatting flows through. This is where caching, fallback options, and defaults live so behavior is identical everywhere. Pass localeMatcher: 'lookup' for strict RFC 4647 lookup matching that mirrors your fallback chain instead of the engine’s looser best fit.
// format/intl.ts — the only place Intl constructors are created
type Kind = 'date' | 'number';
const cache = new Map<string, Intl.DateTimeFormat | Intl.NumberFormat>();
export function getFormatter(
kind: Kind,
locale: string,
options: Intl.DateTimeFormatOptions | Intl.NumberFormatOptions = {},
) {
const key = `${kind}|${locale}|${JSON.stringify(options)}`;
let fmt = cache.get(key);
if (!fmt) {
const base = { localeMatcher: 'lookup' as const, ...options };
fmt = kind === 'date'
? new Intl.DateTimeFormat(locale, base)
: new Intl.NumberFormat(locale, base as Intl.NumberFormatOptions);
cache.set(key, fmt);
}
return fmt;
}
2. Format dates with an explicit time zone
Never let a formatter inherit the host time zone — server and browser will disagree. Pass an explicit timeZone. Use dateStyle/timeStyle for whole presets, or component options for precise control. For deeper coverage of the off-by-one and DST failures this prevents, see Intl.DateTimeFormat time zone & DST bugs.
import { getFormatter } from './intl';
export function formatDate(value: Date, locale: string, timeZone: string) {
return getFormatter('date', locale, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone, // e.g. 'Europe/Berlin' — required, never omit
}).format(value);
}
// formatDate(new Date('2026-03-29T01:30:00Z'), 'de-DE', 'Europe/Berlin')
// → "29.03.2026, 02:30" (CET→CEST DST jump handled by CLDR/TZDB)
3. Format numbers, currency, and units
Intl.NumberFormat covers decimals, percentages, currency, and physical units. Always pass the ISO 4217 currency code separately from the locale — the locale controls presentation, the code controls which currency. Use currencyDisplay: 'narrowSymbol' to collapse US$ to $ where density matters.
const price = getFormatter('number', 'de-DE', {
style: 'currency', currency: 'EUR', currencyDisplay: 'symbol',
}).format(1234.5);
// → "1.234,50 €"
const speed = getFormatter('number', 'en-US', {
style: 'unit', unit: 'kilometer-per-hour', unitDisplay: 'short',
}).format(88);
// → "88 km/h" (never concatenate "km/h" by hand)
4. Format relative time from a computed delta
Intl.RelativeTimeFormat produces “in 3 days” / “vor 2 Stunden” with correct CLDR pluralization. Compute the largest sensible unit yourself, then let the formatter render it. Because plural categories vary by language, the formatter — not your code — must own the word forms; see pluralization rules across languages.
export function formatRelative(deltaSeconds: number, locale: string) {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const abs = Math.abs(deltaSeconds);
if (abs < 3600) return rtf.format(Math.round(deltaSeconds / 60), 'minute');
if (abs < 86400) return rtf.format(Math.round(deltaSeconds / 3600), 'hour');
return rtf.format(Math.round(deltaSeconds / 86400), 'day');
}
// formatRelative(-7200, 'de-DE') → "vor 2 Stunden"
// numeric:'auto' yields "yesterday" instead of "1 day ago" where CLDR has it
5. Use formatToParts when you need to restyle output
When you must wrap the currency symbol in a different color or right-align the integer group, use formatToParts() instead of regex-splitting the string. It returns typed tokens that stay locale-correct.
const parts = getFormatter('number', 'de-DE', {
style: 'currency', currency: 'EUR',
}).formatToParts(1234.5);
// [{type:'integer',value:'1'},{type:'group',value:'.'}, ...,
// {type:'currency',value:'€'}] — wrap parts by type, not by index
Configuration Reference
| Option | Type | Description / default |
|---|---|---|
localeMatcher |
'lookup' | 'best fit' |
Locale negotiation algorithm. 'best fit' (default) is engine-defined; 'lookup' follows RFC 4647 and matches a strict fallback chain. |
timeZone |
IANA string | Time zone for DateTimeFormat. Defaults to the host zone — always set it explicitly (e.g. 'UTC', 'Asia/Tokyo'). |
dateStyle / timeStyle |
'full'|'long'|'medium'|'short' |
Preset date/time formats. Cannot be combined with component options like year/hour. |
calendar |
string | Calendar system, e.g. 'gregory', 'persian', 'buddhist'. Usually set via -u-ca- in the tag; falls back to gregory. |
numberingSystem |
string | Digit set, e.g. 'latn', 'arab'. Set via -u-nu- or option. |
style (number) |
'decimal'|'currency'|'percent'|'unit' |
Selects number formatting mode. Default 'decimal'. |
currency |
ISO 4217 string | Required when style:'currency'. No default — omitting it throws. |
currencyDisplay |
'symbol'|'narrowSymbol'|'code'|'name' |
Symbol rendering. Default 'symbol'. |
unit / unitDisplay |
string / 'short'|'narrow'|'long' |
Required when style:'unit'; unit uses CLDR identifiers like kilometer-per-hour. |
minimum/maximumFractionDigits |
number | Precision bounds. Currency defaults come from CLDR per currency (EUR=2, JPY=0). |
notation |
'standard'|'compact'|'scientific'|'engineering' |
'compact' yields 1.2M. Default 'standard'. |
signDisplay |
'auto'|'always'|'exceptZero'|'negative'|'never' |
Controls the plus/minus sign. 'exceptZero' shows +5 / -5 but bare 0 — useful for deltas. Default 'auto'. |
roundingMode |
'halfExpand'|'halfEven'|'ceil'|'floor'|'trunc'… |
ES2023 rounding control. Default 'halfExpand'; use 'halfEven' (banker’s rounding) for financial sums to avoid cumulative bias. |
numeric (RelativeTimeFormat) |
'always'|'auto' |
'auto' emits idiomatic forms like yesterday. Default 'always'. |
Framework Variants
React / Next.js. Build formatters once per locale outside render and read them via context or a useMemo keyed on locale — constructing Intl objects inside a component body recreates them every render. For server components, pass the resolved locale and time zone down as props so SSR and client hydration call the identical formatter. Libraries like react-intl (formatjs) and next-intl wrap this caching for you and source from the same CLDR data.
Vue / Nuxt. vue-i18n exposes $d (datetime) and $n (number) helpers backed by named format presets you register in the i18n config. Define the presets once (currency, shortDate) and reference them by name in templates so designers can’t drift options per component.
Angular. The DatePipe, DecimalPipe, CurrencyPipe, and PercentPipe read LOCALE_ID and registered locale data (registerLocaleData). Register only the locales you ship to keep the bundle small, and provide DEFAULT_CURRENCY_CODE rather than hardcoding a symbol in templates.
Node.js backend. On the server, the host time zone is whatever the container sets (often UTC, sometimes not) — so passing an explicit timeZone is non-negotiable. Cache formatters at module scope; under load, repeated new Intl.DateTimeFormat is a measurable allocation and CLDR-lookup cost.
Verification
Lock behavior with assertions that pin both the time zone and the locale, then run the suite under multiple TZ values to catch host-zone leakage.
import { describe, it, expect } from 'vitest';
import { formatDate, formatRelative } from '../format';
describe('formatting is host-independent', () => {
it('respects explicit time zone across DST', () => {
const t = new Date('2026-03-29T01:30:00Z');
expect(formatDate(t, 'de-DE', 'Europe/Berlin')).toBe('29.03.2026, 02:30');
});
it('pluralizes relative time per CLDR', () => {
expect(formatRelative(-7200, 'de-DE')).toBe('vor 2 Stunden');
});
});
# Prove no host-timezone dependency by sweeping TZ in CI
for z in America/New_York Europe/Berlin Asia/Tokyo UTC; do
TZ=$z npx vitest run format || exit 1
done
If resolvedOptions() reports a locale you did not request, your ICU data is incomplete — rebuild with full-icu or register the polyfill locale before shipping.
Performance: construct once, format many
Constructing an Intl formatter is expensive relative to using one. Each constructor call runs locale negotiation, resolves the requested options against the available CLDR data, and builds an internal pattern — work that is entirely determined by the locale plus the options object, and therefore work that can be done exactly once. Calling .format() on an existing instance is comparatively trivial.
The practical consequence is that formatter construction belongs outside anything that runs per item. A table that renders two thousand rows of currency should hold one Intl.NumberFormat and call it two thousand times, not construct two thousand formatters. In React that means building the formatter in a module-level cache or a useMemo keyed on locale and options, never in the component body. On the server it means memoizing per locale for the lifetime of the process, since the resolved locale set does not change between requests.
Cache on a composite key. The natural key is the locale tag plus the stringified options, because two call sites asking for the same locale with different options genuinely need different formatters. A three-line memo keyed on `${locale}|${JSON.stringify(options)}` removes the entire cost class without changing a single call site’s behaviour. When you profile a slow list render in a localized app and see Intl frames near the top, this is almost always what you are looking at.
There is one caveat worth naming: formatter instances are not free to hold forever if your locale set is unbounded. If locales come from user input rather than a fixed supported list, cap the cache — an LRU of a few dozen entries is more than enough, because real traffic concentrates on a handful of locales even when the theoretical set is large.
What Intl will not do for you
Intl renders values; it does not model them. Three jobs stay yours.
Currency conversion is the first. Intl.NumberFormat will render an amount in any currency you name, correctly for the locale, but it has no exchange rates and will happily print a euro amount with a dollar sign if you pass the wrong ISO code. The code decides which money; the locale decides how it is written. Keep the amount and its currency code together as one value throughout your system and pass both to the formatter.
Calendar arithmetic is the second. Adding one month to January 31 or one day across a DST boundary is a calendar operation, not a formatting operation, and Intl deliberately has no opinion about it. Until Temporal is universally available, do that arithmetic with a dedicated library or with explicit UTC maths, then hand the resulting instant to the formatter.
Unit conversion is the third. style: 'unit' will render 5 kilometres as 5 km in English and 5 km in German, but it will not turn kilometres into miles for a US reader. Choosing the measurement system is a product decision driven by the region, made before the value reaches the formatter.
Common Pitfalls
- Omitting
timeZone. The formatter inherits the host zone, so SSR (UTC container) and CSR (user’s zone) render different dates and hydration mismatches. Always pass it. See Intl.DateTimeFormat time zone & DST bugs. - Hand-built separators. Assuming
.for decimals or,for thousands breaks inde-DE(1.234,50) and Indian grouping (12,34,567). Lint for rawtoFixed()+ string concatenation and replace withIntl.NumberFormat. - Recreating formatters in hot paths.
new Intl.*inside a render or a per-row loop is a real cost. Cache bylocale + options. - Concatenating units. Building
"88" + " km/h"skips CLDR spacing and plural rules; usestyle:'unit'. - Embedding formatted values into translated sentences with
+. Inject them through placeholders so plurals agree — align with ICU Message Format Deep Dive. - Trusting slim ICU. A minimal build returns
en-shaped output for every locale silently; verify withresolvedOptions()in a smoke test. - Building date and number ranges by hand. Joining two formatted values with an en dash skips locale range separators and shared-field collapsing (
17.–19. Juni). UseIntl.DateTimeFormat.prototype.formatRangeandIntl.NumberFormat.prototype.formatRangeso CLDR owns the join. - Ignoring currency precision. JPY has 0 fraction digits, KWD has 3; never hardcode
2. Let CLDR defaults drive it unless a compliance rule says otherwise.
FAQ
Why does my date show the wrong day near midnight?
The formatter is using the host time zone instead of the user’s. A 2026-01-01T23:30:00Z instant is still December 31 in America/New_York. Pass an explicit timeZone so the calendar day is computed in the right zone rather than the server’s UTC.
Should I cache Intl formatter objects?
Yes. Constructing Intl.DateTimeFormat or Intl.NumberFormat triggers locale negotiation and CLDR data lookup, which is far more expensive than calling .format(). Cache instances keyed by locale plus the stringified options, and reuse them — especially in loops, table renders, and SSR.
How do I format currency without hardcoding the symbol or its position?
Use Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }). The ISO 4217 code chooses the currency; the locale decides symbol glyph, placement, and grouping. de-DE yields 1.234,50 € while en-US yields €1,234.50 from the same call — never position the symbol yourself.
When do I need the @formatjs Intl polyfills?
When you target runtimes that predate full Intl support (older Node without full-icu, or legacy browsers). Install @formatjs/intl-numberformat, @formatjs/intl-datetimeformat, and @formatjs/intl-relativetimeformat plus their locale data, register the locales you ship, and verify with resolvedOptions() that real data loaded.
Can I use Intl for non-Gregorian calendars?
Yes. Pass the calendar via the locale tag, e.g. new Intl.DateTimeFormat('fa-IR-u-ca-persian'), or the calendar option. Unsupported calendars fall back to gregory. The numbering system works the same way through -u-nu- (for example ar-EG-u-nu-arab).
Related
- Intl.NumberFormat currency rounding mismatch — why the cart and the invoice disagree by a cent, and where the single rounding belongs.
- Fallback Chain Configuration — how a usable locale is guaranteed before a formatter is ever constructed.
- ICU Message Format Deep Dive — injecting formatted dates and numbers into translated sentences with correct plurals.
- Intl.DateTimeFormat time zone & DST bugs — the off-by-one and DST failure modes in depth, with fixes.
- Locale Negotiation Strategies — where the resolved locale that drives every formatter comes from.
- Pluralization Rules Across Languages — the CLDR plural categories that relative-time and unit formatting depend on.