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.

Date and number formatting pipeline A raw Date or number plus a resolved locale and explicit options feed Intl formatter construction, which reads CLDR data and is served from a formatter cache to emit locale-correct text identically on server and client. Raw value Date | number Resolved locale de-DE, options Intl construction DateTimeFormat / NumberFormat CLDR data patterns, plurals, TZDB Formatter cache keyed by locale+opts Rendered text SSR = CSR bytes
Value plus locale and explicit options flow through cached Intl formatters backed by CLDR to identical server and client output.

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.

Choosing the Intl constructor for a value A single decision splits four rendering jobs: an instant goes to Intl.DateTimeFormat, a quantity or money amount to Intl.NumberFormat, a time delta to Intl.RelativeTimeFormat, and a series of items to Intl.ListFormat. Each constructor owns its own CLDR data, so mixing them or hand-rolling the output loses locale correctness. Which Intl constructor owns this value? What are you rendering? one value, one locale Intl.DateTimeFormat an instant Intl.NumberFormat quantity, money Intl.RelativeTimeFormat a delta Intl.ListFormat a series
Pick the constructor by what the value *is*; each one carries its own CLDR pattern data.
Same value and options, locale-driven output The value 1234567.5 with style currency and currency EUR passes through Intl.NumberFormat backed by CLDR and emits four distinct strings: de-DE renders 1.234.567,50 euro symbol, en-US renders euro symbol 1,234,567.50, fr-FR renders 1 234 567,50 euro symbol, and en-IN renders euro symbol 12,34,567.50 with Indian grouping. The application code is identical; only the locale differs. Same input 1234567.5 style:'currency' currency:'EUR' Intl.NumberFormat CLDR decides layout de-DE 1.234.567,50 € en-US €1,234,567.50 fr-FR 1 234 567,50 € en-IN Indian grouping €12,34,567.50
One value and one set of options; only the locale changes. CLDR moves the currency symbol, swaps the decimal mark, and even regroups digits (Indian 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 in de-DE (1.234,50) and Indian grouping (12,34,567). Lint for raw toFixed() + string concatenation and replace with Intl.NumberFormat.
  • Recreating formatters in hot paths. new Intl.* inside a render or a per-row loop is a real cost. Cache by locale + options.
  • Concatenating units. Building "88" + " km/h" skips CLDR spacing and plural rules; use style:'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 with resolvedOptions() 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). Use Intl.DateTimeFormat.prototype.formatRange and Intl.NumberFormat.prototype.formatRange so 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).

Part of Core i18n Architecture & Locale Negotiation.