Node slim ICU falling back to English

Prices render as 1,234.5 for German customers. Dates come out in American order. The application code is unchanged, the locale is resolving correctly, and every test passes. The only thing that changed was the base image in the Dockerfile.

The container is running a Node build compiled with small ICU, which contains English locale data and nothing else. Every Intl constructor still exists and still returns an object; each one answers with root-locale behaviour.

How a base-image change reaches a reader A deploy changes the base image, the new container carries a small ICU build, a request for German formatting is answered with root-locale rules, and the reader sees English number formatting with no error raised at any point in the chain. A formatting bug that never errors Deploy Container Intl Reader base image changed small-icu build de-DE requested, root used 1,234.5 — no error anywhere
Four steps, no exception, no log line. The only signal is the output itself.

Root cause: the API is present and the data is not

ECMA-402 lets an implementation ship as little locale data as it likes. A runtime built with --with-intl=small-icu satisfies the whole API surface using data for one locale — English — and resolves every other request to the root locale, whose conventions happen to look English.

Nothing about that is an error condition. new Intl.NumberFormat('de-DE') succeeds, resolvedOptions().locale reports something plausible, and format() returns a string. The specification permits it, so no exception is thrown and no warning is emitted.

This is why the failure is nearly always introduced by an infrastructure change rather than a code change. A team switches to a smaller base image for build speed, or inherits one from a platform template, and every localized number and date in the product quietly changes convention. The application’s own tests pass because they run in whatever runtime CI provides, which may have the same gap.

Four probes and which ones distinguish the builds The reported ICU version is present in both builds and therefore proves nothing. Formatting a German number distinguishes them immediately. Asking which locales are supported returns an empty list on a small build. And formatting a date for Japanese returns English output when the data is absent. Telling a slim build from a full one Full ICU Small ICU process.versions.icu present present — same value NumberFormat("de-DE") 1.234,5 1,234.5 supportedLocalesOf(["de"]) ["de"] [] DateTimeFormat("ja").format() Japanese output English output
The first row is the check most teams write, and it is the one that cannot tell the difference.

The single most misleading check is process.versions.icu. It reports a version in both builds, because both link against ICU — one with the full data and one with a stub. A check for its presence therefore passes on exactly the build it was written to catch.

Minimal reproducible example

# Full ICU
docker run --rm node:20-bookworm-slim \
  node -p "new Intl.NumberFormat('de-DE').format(1234.5)"
#   1.234,5

# A build without the data
docker run --rm some-minimal-image \
  node -p "new Intl.NumberFormat('de-DE').format(1234.5)"
#   1,234.5     ← no error, wrong conventions

The fix: install the data, and prove it during the build

FROM node:20-alpine@sha256:<digest>

# Alpine ships Node without the ICU data; the package is separate.
RUN apk add --no-cache icu-data-full
ENV NODE_ICU_DATA=/usr/share/icu

# Prove it here, so a bad image can never be pushed.
RUN node -e "\
  const n = new Intl.NumberFormat('de-DE').format(1234.5); \
  const l = Intl.NumberFormat.supportedLocalesOf(['de','ja','ar']); \
  if (n !== '1.234,5' || l.length !== 3) { \
    console.error('incomplete ICU data:', n, l); process.exit(1); \
  }"

Two things make this reliable. The probe formats rather than feature-detecting, so it observes the data rather than the API. And it runs during the image build, which turns a silent runtime behaviour into a failed build at the exact moment someone changes the base image — the only point in the process where the person making the change is still looking.

Pinning the base image by digest rather than by tag closes the remaining hole: a tag can be repointed at a rebuilt image with different contents, so a build that passed last week can produce a different runtime today.

Five steps that keep locale data present A formatting probe during the image build fails the build rather than a request. The ICU data package is installed explicitly for base images that omit it. The image is pinned by digest so a rebuild cannot silently change the runtime. Supported locales are logged at boot. And continuous integration asserts the same property, since a slim test runtime makes every formatting assertion meaningless. Closing the gap for good 1 Probe by formatting, at image build time fail the build, not the request 2 Install the data package for the base you use alpine needs it explicitly 3 Pin the image by digest so a rebuild cannot change the runtime 4 Log supported locales at boot the gap becomes a startup line 5 Assert the same in CI a slim test runtime invalidates every format test
The first step is the whole fix: the failure becomes a red build at the moment someone changes the image.

Where else the same gap appears

Containers are the common case and not the only one.

A custom Node build. Anyone compiling Node themselves — for an unusual architecture, or to trim size — chooses the ICU option, and small is the smaller choice.

Bundled or vendored runtimes. Some serverless platforms and desktop wrappers embed their own Node, and the embedded build is not necessarily the official one.

Older Node versions. Full ICU became the default in Node 13, so anything genuinely old may need the external data path even in an official build.

The NODE_ICU_DATA variable pointing nowhere. A build configured to load external data silently falls back when the directory is missing, which happens when a multi-stage Dockerfile copies the application and forgets the data.

That last one is worth calling out because it produces the most confusing symptom: the image contains a full-ICU-capable Node and behaves like a small one, because the data it was told to load is not in the final layer.

Why this survives review

It is worth understanding why a change with this blast radius passes review, because the answer suggests where to put the guard.

The change that causes it does not mention localization. It is a Dockerfile line, usually in a pull request about build times or image size or a security update, reviewed by people thinking about those things. Nothing in the diff says “all number and date formatting will now use English conventions”, and no reviewer is expected to know that a particular base image omits ICU data.

The tests do not catch it because the tests run somewhere else. A CI runner installs Node from its own source, so the suite exercises a runtime that is not the one being changed. Formatting assertions pass, and the image that will actually serve traffic is never asked to format anything until it is in production.

Staging often does not catch it either, because staging is browsed by people who read the interface in English and would not notice English number formatting. The first person to notice is a customer in a market where the convention differs, and their report arrives as “the prices look wrong” some days later.

That chain suggests two places for the guard, and both are cheap. The image build is the moment the change is made, and a failing build there names the cause directly. The container start is the moment the runtime is real, and a log line there gives whoever is diagnosing a later report the answer immediately. Neither requires anyone reviewing a Dockerfile to know anything about ICU, which is the property that makes them work.

Multi-stage builds and the copied-away data

Multi-stage Dockerfiles introduce a variant with the same symptom and a different cause: the build stage has full data, the final stage does not, and the application is copied across without it.

The shape is always the same. An early stage installs the ICU data package to compile or test, the final stage starts from a leaner base for size, and only node_modules and the built application are copied over. The final image then has a Node binary expecting external data and a NODE_ICU_DATA variable pointing at a directory that no longer exists.

Node’s behaviour there is to fall back rather than fail, so the image starts normally and formats everything in English. The build succeeded, the tests in the build stage passed against full data, and the artifact is wrong.

The fix is to run the probe in the final stage, after every copy, rather than in the stage where the data was installed. That is a one-line change and it is the difference between testing the environment you built and testing the one you ship.

Verification

# In the running container — the two lines that answer it
node -p "new Intl.NumberFormat('de-DE').format(1234.5)"
node -p "Intl.NumberFormat.supportedLocalesOf(['de','ja','ar','hi']).join(',')"
#   1.234,5
#   de,ja,ar,hi
// In CI, so a slim runner cannot invalidate the formatting suite
test('the runtime carries full locale data', () => {
  expect(new Intl.NumberFormat('de-DE').format(1234.5)).toBe('1.234,5');
  expect(Intl.NumberFormat.supportedLocalesOf(['de', 'ja', 'ar'])).toHaveLength(3);
});

A boot-time log line naming any of your supported locales the runtime cannot serve is worth adding alongside both. It costs nothing, and it is the line that turns a customer report into a two-minute diagnosis.

When to escalate

If the data is present and formatting is still wrong, the locale reaching the formatter is not the one you think. Log resolvedOptions().locale at the failing call site — a resolved locale of en-US on a German request is a negotiation problem rather than a data one.

If only some locales are wrong, the build may carry a partial data set rather than none. supportedLocalesOf over your full supported list names exactly which ones are missing.

If output differs between the server and the browser, both runtimes need checking independently. Server-rendered HTML formatted with root-locale rules and hydrated by a browser with full data produces a hydration mismatch — the class of failure covered in hydration mismatch after locale switch, with an infrastructural cause.

FAQ

Is a polyfill the right fix on the server?

Rarely. A polyfill spends memory and startup time on every instance to work around an image you control. Installing the data is cheaper and simpler; the polyfill exists for runtimes that cannot be changed.

How much larger is a full-ICU image?

Roughly tens of megabytes for the data. Compared against serving every localized number and date with the wrong conventions, that is not a close trade for a product with international customers.

Does this affect toLocaleString too?

Yes — Number.prototype.toLocaleString and the equivalents on Date delegate to the same Intl machinery, so they inherit the gap exactly. Code that avoided Intl by using them is affected identically.

Can I detect it from the browser side?

You do not need to: browsers ship full data. What is worth detecting from the client is a disagreement between server-rendered and client-rendered values, which is a cheap assertion in an end-to-end test and catches this along with several other causes.

Part of Intl Polyfills & ICU Data Loading.