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.
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.
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.
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.
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 metadata — process.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.
Related
- Node slim ICU falling back to English — the base-image change that switches every locale to English without an error.
- Intl polyfill bundle size per locale — conditional loading, split per constructor, one resolved locale at a time.
- Stale timezone data in a pinned runtime — why a scheduled meeting drifts by an hour months after it was booked.
- Date & Number Formatting Standards — the APIs this data backs.
- Unicode Text Handling & Collation — collation and segmentation, which need the same data.
- Intl.DateTimeFormat timezone & DST bugs — what stale zone data looks like from the outside.
- Pluralization Rules Across Languages — plural rules come from the same CLDR data.