Intl Polyfills & ICU Data Loading

new Intl.NumberFormat('de-DE').format(1234.5) returns 1,234.5 in a container that should have produced 1.234,5. Nothing throws. Every price on the site is formatted with English conventions, and the only signal is that a German customer eventually complains.

The Intl APIs are a thin interface over a large database of locale rules. When that data is missing, most implementations do not fail — they fall back to the root locale, which looks like English, and carry on.

Four sources of locale data behind one constructor A formatter constructor is answered by whatever locale data the runtime has: a full ICU build covers every locale without a download, a slim build silently answers in English, a polyfill answers from data you ship yourself, and a runtime with neither either throws or returns the wrong output. Where does the locale data come from? new Intl.NumberFormat("de-DE") which data answers this? Full ICU in the runtime every locale, no download Slim ICU English only — silent fallback Polyfill + locale data you ship the data No data at all RangeError, or wrong output
The second branch is the dangerous one: it produces plausible English output and no error.

This page belongs to Core i18n Architecture & Locale Negotiation because every other layer assumes it: negotiation resolves a tag, the catalogue supplies a string, and formatting turns values into text — but only if the data for that tag exists.

Prerequisites

Concept & spec — the data, not the API, is the dependency

ECMA-402 defines the behaviour of the Intl constructors and defers every actual pattern to the Unicode CLDR: how a date is ordered, which separator groups thousands, what a currency symbol looks like and where it goes, which plural categories exist, how strings collate. That data is compiled into ICU, and a runtime bundles as much of it as it chooses.

Three consequences follow, and each produces a distinct failure.

A runtime can have the API and not the data. Every Intl constructor exists, none of them throws, and all of them answer with root-locale behaviour. This is the silent failure, and it is the most common one in containerised deployments.

A runtime can lack the API entirely. Older environments predate Intl.RelativeTimeFormat, Intl.ListFormat or Intl.Segmenter, so the constructor is undefined and the code throws. Loud, and therefore easier.

A runtime can have stale data. ICU data includes the time zone database, currency definitions and locale rules, all of which change. A pinned runtime image from two years ago has two-year-old rules, and the failure shows up only for the zones or currencies that changed — the mechanism behind the historical-offset bugs in Intl.DateTimeFormat timezone and DST bugs.

Locale data availability by runtime Modern browsers ship full ICU data. Official Node builds from version 14 onward include it by default. A Node binary compiled with the small-icu option carries English only. Alpine and musl-based images depend on whether an ICU data package was installed. Edge runtimes usually carry full data but are worth verifying with one call at boot. Which runtimes carry which data Locale data What to check Modern browsers full, built in nothing Node 14+ official builds full ICU by default process.versions.icu Node built --with-intl=small English only a spot-check format Alpine/musl images depends on the icu-data package the image, not Node Workers / edge runtimes usually full one format call at boot
Only the third and fourth rows go wrong, and both go wrong silently.

Step-by-step implementation

1. Detect the gap rather than assuming it

A feature check for the constructor is not enough, because the constructor exists in a slim build. The reliable test formats a value whose output differs between the root locale and a real one.

export function hasFullIcu(): boolean {
  try {
    // German groups with '.' and decimalises with ','; the root locale does not.
    const formatted = new Intl.NumberFormat('de-DE').format(1234.5);
    if (formatted !== '1.234,5') return false;
    // A locale whose data is often absent from partial builds.
    return new Intl.DateTimeFormat('ja-JP', { era: 'long' })
      .resolvedOptions().locale.startsWith('ja');
  } catch {
    return false;
  }
}

Two checks rather than one, because a build can carry a handful of major European locales and nothing else — which passes the first test and fails for half your markets.

2. Load the polyfill conditionally

export async function ensureIntl(locale: string): Promise<void> {
  if (hasFullIcu()) return;                     // the common path costs nothing

  const [{ default: NumberFormat }] = await Promise.all([
    import('@formatjs/intl-numberformat/polyfill-force'),
    import('@formatjs/intl-datetimeformat/polyfill-force'),
  ]);

  // Exactly one locale: the resolved one, not the supported list.
  await Promise.all([
    import(`@formatjs/intl-numberformat/locale-data/${locale}`),
    import(`@formatjs/intl-datetimeformat/locale-data/${locale}`),
  ]);
}

The dynamic import is what keeps this free for the ninety-plus percent of readers whose browser already has the data. An unconditional import ships the polyfill to everybody in order to help nobody.

Approximate polyfill payload as locales are added A formatting polyfill starts with a fixed core and grows with each locale data file added. One locale roughly adds a tenth again; five locales double it; twenty locales multiply it several times over. The growth is why locale data must be loaded per resolved locale rather than bundled wholesale. What locale data costs to ship Polyfill core only 32 kB + 1 locale 41 kB + 5 locales 76 kB + 20 locales 210 kB
Indicative figures — the shape is what matters: data grows with locales, core does not.

3. Register before the first format call

Polyfill registration is asynchronous, and a formatter constructed before it completes uses the unpatched implementation. The natural place to await it is wherever the message catalogue is already awaited, since both are prerequisites for rendering.

// One await, at the same boundary that loads messages for the resolved locale.
const [messages] = await Promise.all([
  loadMessages(locale),
  ensureIntl(locale),
]);

4. Fix the server side at the image level

On the server the answer is usually not a polyfill but a correct runtime. Official Node builds since version 14 include full ICU; the problems come from custom builds and from minimal base images.

# Alpine: the ICU data package is separate from Node itself
FROM node:20-alpine
RUN apk add --no-cache icu-data-full
ENV NODE_ICU_DATA=/usr/share/icu

# Verify at build time, so a bad image never reaches a registry
RUN node -e "const s=new Intl.NumberFormat('de-DE').format(1234.5); \
  if (s !== '1.234,5') { console.error('slim ICU:', s); process.exit(1) }"

Asserting during the image build is the single highest-value line here: it converts a silent runtime behaviour into a failed build, at the exact moment someone changes the base image.

Conditional polyfill loading in five steps A single boot-time check establishes whether the runtime formats a representative locale correctly. The polyfill is dynamically imported only when it does not. Exactly one locale data file is loaded, the resolved one. Registration happens before the first formatting call, alongside catalogue loading. And continuous integration asserts full data so the gap is found before deployment. Loading only the data the reader needs 1 Detect the gap at boot, once does the runtime format de-DE correctly? 2 Load the polyfill only if it does not dynamic import, never unconditional 3 Load one locale — the resolved one not the supported list 4 Register before the first format call await it in the same place the catalogue loads 5 Fail loudly in CI, not in production assert full data in the test runtime
Step three is where the payload is won: one locale, chosen at runtime, not the whole supported list.

5. Assert the same thing in CI

test('the test runtime has full locale data', () => {
  expect(new Intl.NumberFormat('de-DE').format(1234.5)).toBe('1.234,5');
  expect(new Intl.DateTimeFormat('ar-EG').resolvedOptions().locale).toMatch(/^ar/);
});

Without this, a suite running on a slim runtime passes assertions written against English output, and the formatting tests silently stop testing formatting.

Configuration reference

Concern Setting Notes
Node build --with-intl=full-icu Default for official builds since Node 14. Custom builds may differ.
Node data path NODE_ICU_DATA Points at an external ICU data directory; needed with small-icu builds.
Alpine images apk add icu-data-full Node in Alpine does not carry the data by itself.
Polyfill packages @formatjs/intl-* One per constructor; polyfill-force replaces even a present implementation.
Locale data import locale-data/<tag> One file per locale per constructor. Import the resolved locale only.
Time zone data add-all-tz / runtime upgrade Stale zone rules produce wrong historical offsets.
Verification one format call at boot The only check that distinguishes full data from a plausible fallback.

Framework variants

React / Next.js. Do the check and the conditional import in the same place the locale is resolved, then await both before rendering anything formatted. In the App Router the server side needs its own assertion, because the server and the browser have independent runtimes and only one of them is likely to be wrong.

Vue / Nuxt. A plugin that runs before the i18n module is the natural home. Nuxt’s server and client entry points are separate, and the server one is the one that ends up in a container.

Angular. With build-time locale inlining, the formatting pipes read from Angular’s own locale data registration rather than from Intl in some configurations — so both need checking. Registering the locale data explicitly at bootstrap is the documented path and is worth doing even when Intl is complete.

Edge runtimes. Cloudflare Workers, Deno Deploy and similar environments generally carry full ICU, but they are also the environments where a runtime upgrade can change data versions under you. A boot assertion costs microseconds and catches it.

Verification

# Node: is the data there?
node -p "process.versions.icu"                       # e.g. 74.2 — absent on small-icu
node -p "new Intl.NumberFormat('de-DE').format(1234.5)"
#   1.234,5      ← full data
#   1,234.5      ← slim: root-locale fallback, no error

# Which locales does this runtime actually know?
node -p "Intl.NumberFormat.supportedLocalesOf(['de','ja','ar','hi']).join(',')"
#   de,ja,ar,hi  ← full
#   (empty)      ← slim

supportedLocalesOf is the most direct answer and the one worth putting in a startup log line: it names exactly which of your supported locales the runtime can serve.

Budgeting the payload when a polyfill is unavoidable

Some products cannot rely on the runtime — an embedded webview, a corporate browser fleet, a device class that lags years behind. When the polyfill is a certainty rather than a fallback, the question becomes how much of it to ship and when.

The first decision is which constructors you actually use. The polyfill packages are per constructor, and most products use two or three: number formatting, date formatting, and plural rules. Shipping the whole family because it is convenient roughly triples the core payload for functionality nothing calls.

The second is when the locale data loads. Data for the resolved locale is required before the first formatted value renders, which makes it a blocking dependency of first paint — the same position the message catalogue occupies. Loading them together, in one await, is both simpler and faster than two sequential fetches, and it means a single loading state covers both.

The third is what happens on a locale switch. The new locale needs its data before anything re-renders, so a switch has the same shape as an initial load: fetch catalogue and data, then re-render. Treating them as one unit avoids the half-switched state where strings are German and numbers are still English.

There is a fourth consideration that is easy to miss: the polyfill and the native implementation can disagree. Output differs in small ways — a non-breaking space versus a regular one before a currency symbol, a slightly different pattern for an uncommon locale — because the polyfill is compiled from one CLDR version and the runtime from another. Any test asserting an exact formatted string will pass in one environment and fail in the other. Asserting on formatToParts rather than on the concatenated string sidesteps most of it, because the part types are stable even when the literals are not.

Keeping data versions predictable

Locale data is a dependency that changes without a version bump in your package.json, and that is what makes it awkward to reason about.

The rules move for real reasons. Time zones change when governments change them, and several do every year. Currencies are added, redenominated and withdrawn. Plural rules and collation tailorings are occasionally corrected. CLDR ships twice a year, ICU follows, and runtimes pick it up whenever they pick it up.

Three habits keep that from becoming a surprise. Record the ICU version alongside your build metadataprocess.versions.icu on the server, and the resolved options of a representative formatter in the browser — so a support conversation can start with which data version the reader had. Pin the runtime image by digest rather than by tag, so a data change arrives when you choose rather than on a rebuild. And assert the behaviour you depend on, not the version: a test that checks a specific zone’s historical offset is meaningful, whereas a test that checks the ICU version number breaks on every upgrade for no reason.

The one case where a version genuinely matters is a polyfill and a runtime coexisting, since then two data versions are live in the same process. Forcing the polyfill for every reader — the polyfill-force entry point — is the way to make behaviour uniform when uniformity matters more than payload, which is a legitimate choice for a product where a formatted value is a legal or financial artifact rather than a convenience.

Common pitfalls

  • Feature-detecting the constructor. It exists in slim builds. Detect the data by formatting something.
  • Importing the polyfill unconditionally. Ships tens of kilobytes to readers whose browser already had the data.
  • Loading every supported locale’s data. The payload grows with your market count for no benefit; load the resolved locale.
  • Formatting before registration completes. The polyfill is async; a formatter built earlier uses the old implementation.
  • Trusting a base-image change. Switching to a slimmer image silently changes formatting for every locale.
  • Pinning a runtime for years. Time zone and currency rules change; stale data produces confidently wrong output.

Diagnosing a report of “wrong number formatting”

Support tickets about formatting rarely name the cause, and the same symptom has four possible sources. Working through them in order takes minutes and avoids rewriting formatting code that was never wrong.

Ask what the reader saw and where. A price wrong in an email and right on the page points at the server runtime rather than the browser. A price wrong for one reader and right for another points at negotiation, not at data. A price wrong for everybody points at the code or the runtime shared by everybody.

Reproduce with the resolved locale, not the assumed one. Logging new Intl.NumberFormat(locale).resolvedOptions() at the point of failure answers two questions at once: which locale was actually used, and whether the runtime honoured it. A resolved locale of en-US when the reader is German is a negotiation bug; a resolved locale of de-DE producing English output is a data bug.

Check the data before checking the code. One supportedLocalesOf call distinguishes a runtime that cannot serve the locale from code that never asked it to. This is the step most often skipped, and it is the one that separates the two largest categories of cause.

Only then look at the options object. Wrong currency, wrong fraction digits and wrong rounding are code problems, and they are covered in Intl.NumberFormat currency rounding mismatch rather than here.

The reason to fix the order is that the first three steps are cheap and the fourth is expensive. A team that starts by auditing formatter options will find something to change, ship it, and discover the container was serving English all along.

FAQ

Do modern browsers still need a polyfill?

For the long-standing constructors, no. For newer ones — Intl.Segmenter, Intl.DurationFormat — support is more recent, so a conditional polyfill is still worthwhile if you use them and support older browsers. The conditional part is what keeps it cheap.

How do I know which locales a runtime supports?

Intl.NumberFormat.supportedLocalesOf(list) returns the subset it can serve. Comparing that against your supported list at boot, and logging the difference, turns an invisible gap into a startup warning.

Is full-icu still needed as a package?

Not for current official Node builds, which include full data. It remains relevant for custom builds, for minimal container images, and for older versions still in production — which is precisely where this problem tends to live.

Does locale data affect bundle size on the server?

Not in the way it does in a browser: the data lives in the runtime rather than in your bundle. It affects image size, which is why minimal images drop it in the first place, and that trade is usually a poor one for a localized product.

What about Intl.Segmenter specifically?

It is the newest of the family and the most likely to be missing. Because it is used for counting and truncation rather than for display, a missing implementation tends to surface as broken emoji rather than as wrong-looking numbers — see Unicode text handling and collation.

Should the boot check run in production, or only in CI?

Both, in different forms. In CI it should fail the build, because a test runtime without full data silently invalidates every formatting assertion. In production it should log rather than throw: a warning line naming the locales the runtime cannot serve gives you the evidence when a support ticket arrives, whereas refusing to start turns a formatting degradation into an outage.

Does a service worker complicate polyfill loading?

It can, because a worker caching strategy that serves stale assets may hold an old locale data file after a deploy. Treat locale data like any other hashed asset — a content hash in the URL means a new file is a new URL, and the cache cannot serve the old one. Locale data files are also worth keeping out of a precache list, since precaching every locale defeats the reason for loading one.

Is there a way to avoid the polyfill entirely on the server?

Yes, and it is the better answer: use a runtime that carries the data. A polyfill on the server pays a memory and startup cost on every instance to work around an image that could simply include ICU. The polyfill exists for environments you cannot change, and a container image is nearly always one you can.

Part of Core i18n Architecture & Locale Negotiation.