Shrinking Bundled Locale and Timezone Data
Data hides better than code in a bundle. A single 40 KB module is obvious in a treemap; 127 locale files of 300 bytes each look like noise around the edges, and together they can outweigh everything in the middle.
The analyzer output for a typical application that supports two languages:
node_modules/moment/locale/ 41.1 kB 127 files
node_modules/moment-timezone/ 183.2 kB data/packed/latest.json
node_modules/date-fns/locale/ 28.6 kB 84 directories
Nearly a quarter of a megabyte of data, of which the application needs the equivalent of about 1 KB. The remainder is Kazakh month names and the daylight-saving history of Ulaanbaatar since 1905.
This is a distinct problem from the library replacement covered in replacing Moment.js with date-fns or Temporal, because it survives the migration: a modular library still ships every locale you make reachable.
Root cause: a runtime-selected import matches every file
The locale a user needs is known at runtime, not at build time, so the natural code is a dynamic path:
// This is what pulls in all 127 locale files.
require(`./locale/${userLocale}`);Webpack resolves this into a context module: a synthetic module containing every file matching the directory and pattern, with a runtime map from the expression’s value to the right entry. The bundler cannot narrow it, because userLocale is only known once the code runs. Rollup and Vite behave equivalently for a glob-resolved dynamic import.
The same mechanism explains the zone database. A packed IANA dataset is one large JSON asset with no internal module structure, so no amount of tree-shaking touches it — the file is either imported or it is not.
Fix 1: restrict the context at build time
Webpack exposes a plugin specifically for narrowing context modules:
// webpack.config.js — Webpack 5
const webpack = require('webpack');
module.exports = {
plugins: [
// Narrow the locale context to the languages this product actually ships.
// Adding a locale to the product means adding it here — which is a feature:
// an unsupported locale fails loudly at build time rather than silently.
new webpack.ContextReplacementPlugin(
/moment[\\/]locale$/,
/^\.\/(en-gb|fr|de)$/
),
],
};Vite has no direct equivalent, because Rollup resolves dynamic imports through glob patterns instead. The narrowing therefore happens in the source, where an explicit glob is both the mechanism and the documentation:
// locale-loader.js — Vite 5+: an explicit glob is the allowlist
// import.meta.glob resolves at build time; only matched files enter the graph.
const loaders = import.meta.glob('../node_modules/date-fns/locale/{en-GB,fr,de}/index.js');
export async function loadLocale(code) {
const key = `../node_modules/date-fns/locale/${code}/index.js`;
// Fall back rather than throwing: an unknown code must not break rendering.
const loader = loaders[key] || loaders['../node_modules/date-fns/locale/en-GB/index.js'];
return (await loader()).default;
}Fix 2: load the active locale on demand
Even a narrowed set should not all ship in the initial chunk when the session uses one. A dynamic import keyed on a validated identifier keeps each locale in its own chunk and fetches exactly one — the same pattern as any other on-demand dynamic import.
// i18n.js — one locale chunk per session, chosen from a fixed allowlist
const SUPPORTED = ['en-GB', 'fr', 'de'];
export async function activateLocale(requested) {
// Validate before interpolating: an unvalidated value both breaks the
// context narrowing and lets an arbitrary path into the resolver.
const code = SUPPORTED.includes(requested) ? requested : 'en-GB';
const { default: locale } = await import(
/* webpackChunkName: "locale-[request]" */ `./locales/${code}.js`
);
return locale;
}The named chunk pattern matters for diagnosis: without it, locale chunks appear in the network panel as opaque hashes, and confirming that only one was fetched becomes guesswork.
Fix 3: stop bundling the timezone database
The packed IANA dataset is the single largest data payload in this category, and in a browser it is almost always redundant — the runtime already has one.
// zones.js — zero bundled zone data
// The browser's own IANA database backs this; no dependency, no payload.
export function formatInZone(instant, timeZone, locale = 'en-GB') {
return new Intl.DateTimeFormat(locale, {
timeZone,
dateStyle: 'medium',
timeStyle: 'short',
}).format(instant);
}
export function zoneOffsetMinutes(date, timeZone) {
// Derive the offset from formatted parts rather than a bundled table.
const dtf = new Intl.DateTimeFormat('en-GB', {
timeZone, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
const parts = Object.fromEntries(dtf.formatToParts(date).map((p) => [p.type, p.value]));
const asUTC = Date.UTC(parts.year, parts.month - 1, parts.day,
parts.hour, parts.minute, parts.second);
return (asUTC - date.getTime()) / 60000;
}If a bundled dataset is genuinely required — a server environment pinned to a specific data version, or arithmetic the platform API cannot express — bundle the subset. Most zone packages publish year-range builds, and restricting to the decade your data covers typically removes 80% of the payload.
Step-by-step verification
-
Confirm the context narrowed. Re-run the attribution and count the locale files in the output. If all of them are still present, the pattern does not match the directory the library actually uses.
-
Confirm one locale chunk per session. Load the application and check that exactly one locale chunk is requested, matching the active language.
-
Confirm an unsupported locale degrades safely. Force an unsupported code and verify the fallback renders rather than throwing or producing an empty screen.
-
Confirm zone formatting still matches. Format the same instant in three zones, including one currently in daylight saving, and compare against the previous implementation.
-
Check the server build separately. Server bundles are configured independently and frequently keep the full dataset long after the client build has dropped it.
-
Add the budget. Lock the initial chunk size so a future
import('./locale/' + code)without validation cannot silently reintroduce the whole directory — see failing builds on bundle size regressions.
Edge cases and gotchas
Regional variants. Narrowing to fr while the application requests fr-CA produces a runtime miss and a fallback to the default locale — visible as subtly wrong date order rather than an error. Include every variant you actually serve in the pattern.
Locale data outside date libraries. Number formatting, pluralization rules, and collation tables live in their own packages with the same problem and the same fix. The platform’s internationalization API covers most of them with zero bundled data.
Server-rendered locale mismatch. If the server renders with one locale and the client activates another, hydration mismatches follow. Resolve the locale once, on the server, and pass it to the client rather than re-deriving it.
Currency and unit data. Formatting money or units pulls a separate data table in some libraries. Check for it specifically; it is easy to miss beside the larger locale directory.
FAQ
Why do all 127 locales end up in the bundle when I only use two?
Because the locale is selected with a runtime expression, and the bundler must therefore assume any locale file could be needed. A require or import built from a variable creates a context that matches every file in the locale directory, and all of them are included. The bundler is not being conservative by choice — it genuinely cannot prove which one your code will ask for, so it ships them all.
Is the timezone database really necessary in the browser?
Usually not. Every modern browser already ships the IANA zone database and exposes it through the platform’s internationalization formatting API, which can format an instant in any named zone without a single bundled byte. A bundled zone database is only justified when you need arithmetic that the platform API does not express, or when you must produce identical results in a server environment whose data version you control.
Should locale files be split per locale or bundled together?
Per locale, loaded dynamically, whenever the application supports more than about three. Each locale file is small, but the set is not, and a session uses exactly one. Bundling them together means every user downloads every language. Below three supported locales the request overhead can outweigh the saving, and including them statically is defensible.
Related
- Auditing and Replacing Heavy Dependencies — the parent guide on ranking and fixing heavy packages
- Replacing Moment.js With date-fns or Temporal — the library migration this data problem outlives
- Configuring Browserslist to Drop Legacy Polyfills — the other category of payload shipped for environments you do not target
- Dynamic Import Patterns for On-Demand Loading — the loading pattern the locale chunk uses