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.

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'.
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.

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.
  • 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.