Intl polyfill bundle size per locale

A build adds @formatjs polyfills to support an older browser fleet, and the main bundle grows by a couple of hundred kilobytes. Every reader downloads it, including the ninety-odd percent whose browser has had the data built in for years, and including data for nineteen locales they will never see.

The polyfills are not the problem. Loading them unconditionally, and loading every locale’s data with them, is.

Polyfill payload by loading strategy A reader whose browser already has the data downloads nothing when the polyfill is conditional. Importing the core unconditionally costs every reader tens of kilobytes to help the few who need it. Bundling the core together with data for every supported locale multiplies that several times over, for data no single reader uses. What each import decision costs a reader No polyfill (modern browser) 0 kB the common case Core, conditional 0 kB 0 for most readers Core, unconditional 34 kB Core + all supported locales 190 kB
The first two rows are the same code with one difference: whether the import is behind a check.

Root cause: two separate decisions, usually made as one

A polyfill has two parts with very different cost profiles, and importing them together hides that.

The core implements the algorithm — how to lay out a formatted number, how to build a date pattern. It is a fixed cost, tens of kilobytes, and it is needed only by readers whose runtime lacks the API or the data.

The locale data is the CLDR rules for one locale. It is per locale, it is required by anyone using the polyfill, and it multiplies: nineteen locales is nineteen files.

A static import at the top of a module fetches both for everybody. Because the code is unconditional, bundlers cannot remove it, and because the locale data is imported by a static path, every locale ends up in the graph.

Polyfill packages and their per-locale data cost Number formatting is needed wherever a number renders and its locale data is small. Date formatting is needed wherever a date renders and carries the largest data because it includes time zone information. Plural rules are needed by any ICU plural and are tiny. Relative time formatting is small. Segmentation needs no per-locale data because its rules live in the core. Which polyfill pieces a page actually needs Needed when Data per locale NumberFormat any number is shown small DateTimeFormat any date is shown largest — includes zones PluralRules any ICU plural renders tiny RelativeTimeFormat a relative time is shown small Segmenter text is counted or cut none — rules are in the core
Date formatting dominates the budget, which is why loading it per locale rather than wholesale matters most.

The date formatting package dominates the total, because its data includes time zone display names. On a product formatting only numbers, importing the date polyfill because it came in the same documentation example roughly triples the payload for functionality nothing calls.

Minimal reproducible example

// Every reader pays for all of this, whether they need it or not.
import '@formatjs/intl-numberformat/polyfill';
import '@formatjs/intl-numberformat/locale-data/en';
import '@formatjs/intl-numberformat/locale-data/de';
import '@formatjs/intl-numberformat/locale-data/fr';
import '@formatjs/intl-numberformat/locale-data/ja';
// …fifteen more
import '@formatjs/intl-datetimeformat/polyfill';
import '@formatjs/intl-datetimeformat/add-all-tz';   // the largest single import here

The fix: conditional, per constructor, per resolved locale

type Need = 'number' | 'datetime' | 'plural';

// A probe per constructor: does this runtime format this locale correctly?
const probes: Record<Need, (l: string) => boolean> = {
  number:   (l) => new Intl.NumberFormat(l).resolvedOptions().locale.startsWith(l.split('-')[0]),
  datetime: (l) => new Intl.DateTimeFormat(l).resolvedOptions().locale.startsWith(l.split('-')[0]),
  plural:   (l) => new Intl.PluralRules(l).resolvedOptions().locale.startsWith(l.split('-')[0]),
};

export async function ensureIntl(locale: string, needs: Need[]): Promise<void> {
  await Promise.all(needs.map(async (need) => {
    try { if (probes[need](locale)) return; } catch { /* fall through and polyfill */ }

    if (need === 'number') {
      await import('@formatjs/intl-numberformat/polyfill-force');
      await import(`@formatjs/intl-numberformat/locale-data/${locale}`);
    } else if (need === 'datetime') {
      await import('@formatjs/intl-datetimeformat/polyfill-force');
      await import(`@formatjs/intl-datetimeformat/locale-data/${locale}`);
      await import('@formatjs/intl-datetimeformat/add-golden-tz');   // not add-all-tz
    } else {
      await import('@formatjs/intl-pluralrules/polyfill-force');
      await import(`@formatjs/intl-pluralrules/locale-data/${locale}`);
    }
  }));
}

Three decisions do the work. The probe formats rather than checking for the constructor, so a runtime with the API and no data is caught. The imports are dynamic, so a reader who passes the probe downloads nothing. And the locale is a variable, so the bundler emits one chunk per locale and fetches exactly one.

Five rules for a minimal polyfill payload Only the constructors a page uses are imported. The import is gated on a formatting probe rather than a feature check. Exactly one locale data file per constructor is fetched, for the resolved locale. The await sits beside the message catalogue so one loading state covers both. And a locale switch fetches the new data the same way messages are fetched. Loading the minimum, at the right moment 1 Split by constructor import only the ones the page calls 2 Gate on a formatting probe not on typeof Intl.X 3 Import the resolved locale only one dynamic import, one file 4 Await beside the catalogue one loading state covers both 5 Re-import on a locale switch data is per locale, like messages
Rules three and five are the same rule: locale data is per locale, exactly like a message catalogue.

Making the bundler cooperate

A dynamic import with a template literal is what lets a bundler split per locale, and it needs a little care to produce the chunks you expect.

Modern bundlers analyse the template and emit one chunk per matching file, which is the desired outcome — but only if the variable part is a simple interpolation. Building the path in a helper, passing a full path as an argument, or concatenating with a variable prefix defeats the analysis and produces either one enormous chunk or a build error.

It is also worth constraining the set explicitly, so an unexpected locale value cannot request a chunk that does not exist:

const SUPPORTED = ['en', 'de', 'fr', 'ja', 'ar'] as const;
const data = SUPPORTED.includes(locale as any) ? locale : 'en';
await import(`@formatjs/intl-numberformat/locale-data/${data}`);

Verifying the outcome is a build-output question rather than a code one: after building, the emitted chunk list should contain one small file per locale rather than one large file containing all of them. If it does not, the import path is not analysable, and no amount of runtime conditionality will help.

Time zone data deserves its own decision

@formatjs/intl-datetimeformat offers several time zone data bundles, and the difference between them is large. The complete set covers every IANA zone and is the biggest single import in this area; a reduced “golden” set covers the commonly used zones at a fraction of the size.

The right choice depends on what your product does with zones. An application formatting timestamps in the reader’s own zone needs only that zone to be present, which the reduced set nearly always satisfies. An application letting readers pick any zone — a scheduling tool, a travel product — needs the full set, and should load it lazily at the point the picker opens rather than at boot.

Getting this wrong in the expensive direction is the most common single cause of an oversized localization payload, and it is usually copied from a documentation example rather than chosen.

Deciding whether the polyfill is needed at all

Before optimising the loading, it is worth checking whether the polyfill has a job. The answer is often no, and the cheapest kilobyte is the one nobody ships.

The relevant question is not “which browsers do we support” but “which of our readers lack this specific data”, and the two differ enormously. The long-standing constructors — number, date, plural and collation formatting — have been present with full data in every mainstream browser for many years, so a product whose analytics show a modern browser distribution needs no polyfill for them at all. The newer arrivals are a different matter, and Intl.Segmenter in particular is recent enough that a polyfill is still a reasonable default if you use it.

There is a second population worth measuring separately: embedded webviews. A product accessed through an in-app browser, a kiosk, a point-of-sale terminal or a smart-TV shell may be running an engine years behind anything in the analytics, and those readers are usually invisible in browser-share statistics because they report an unhelpful user agent.

The practical way to settle it is to measure rather than to guess. A one-line beacon that runs the same formatting probe used above and reports the result gives a real distribution within a day, and it costs nothing to leave in place.

That number then decides the strategy. If essentially nobody fails the probe, the conditional import is correct and will almost never fire. If a meaningful share fails, the conditional import is still correct — but it is worth checking that the loading state it introduces is designed rather than accidental, because for those readers it is on the critical path to first paint.

Verification

# One chunk per locale, not one chunk containing every locale
npx vite build && ls -la dist/assets | grep -E 'locale-data|intl' | head

# What a reader actually downloads: load the page in a modern browser and
# confirm no polyfill chunk appears in the network panel at all.
test('a modern runtime downloads no polyfill', async () => {
  const requested: string[] = [];
  page.on('request', (r) => requested.push(r.url()));
  await page.goto('/de/pricing');
  expect(requested.filter((u) => /intl-(numberformat|datetimeformat)/.test(u))).toHaveLength(0);
});

That end-to-end assertion is the one worth keeping. Bundle-size checks drift; an assertion that a modern browser fetches no polyfill directly encodes the intent.

When to escalate

If chunks are emitted per locale but all of them are fetched, something is importing them eagerly — often a barrel file re-exporting every locale, which the bundler cannot tree-shake through.

If the polyfill loads for readers who should not need it, the probe is too strict. Comparing resolvedOptions().locale against the full requested tag fails for legitimate fallbacks such as de-AT resolving to de; comparing the language subtag, as above, avoids that.

If payload is acceptable but first paint regressed, the await is on the critical path. Loading data beside the message catalogue rather than after it makes the two concurrent, which is usually enough — the same ordering argument as Nuxt i18n lazy messages missing after a locale switch.

FAQ

Should the polyfill ever be unconditional?

Only when uniformity matters more than payload — when a formatted value is a financial or legal artifact and must be byte-identical across every runtime. That is a real requirement in some products, and it is a deliberate trade rather than a default.

Does this apply to server-side rendering?

The core does not: a server should have the data rather than a polyfill. What does apply is that server and client must produce identical output, so a client polyfilled from one CLDR version and a server on another can produce a hydration mismatch on a formatted number.

How do I know which constructors a page uses?

A lint rule or a simple grep over the source is enough to start, and the honest answer for most products is number formatting plus plural rules, with date formatting on a subset of pages. That distribution is exactly why splitting by constructor pays.

Is add-all-tz ever the right choice?

When readers choose arbitrary time zones — scheduling, travel, calendaring. Even then it is better loaded lazily at the moment a zone picker opens than included at boot, because most sessions never open it.

Part of Intl Polyfills & ICU Data Loading.