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.
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.
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.
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.
Related
- Intl Polyfills & ICU Data Loading — the detection and loading model this tunes.
- Node slim ICU falling back to English — the server-side version of the same gap.
- Date & Number Formatting Standards — what the polyfilled constructors are for.
- Framework i18n & Component Routing — where the awaits sit relative to rendering.
Part of Intl Polyfills & ICU Data Loading.