Cut Flutter Web Bundles: gen-l10n use-deferred-loading
Every locale you add to a Flutter app compiles into your web build. Ship 30 languages and dart2js bakes 30 sets of translated strings into main.dart.js — even though any given visitor uses exactly one of them. The Flutter Gallery app supported over 70 languages, and when the team switched its localizations to deferred loading, the initial JavaScript bundle size was cut roughly in half.
The fix is a single line in l10n.yaml: use-deferred-loading: true. This post covers what the flag actually generates, why it only helps on the web (it's a dart2js feature), when it makes startup worse, and how to prove the split happened in Chrome DevTools.
Why every locale ships in your initial bundle
Without deferred loading, flutter gen-l10n generates one Dart file per locale (app_localizations_es.dart, app_localizations_de.dart, …) and imports them all eagerly from the main app_localizations.dart:
// Generated without deferred loading — every locale is a normal import.
import 'app_localizations_de.dart';
import 'app_localizations_es.dart';
import 'app_localizations_ja.dart';
Eager imports mean dart2js has no choice: all of those message classes land in main.dart.js, and the browser downloads, parses, and compiles every language before first paint. Tree shaking can't help — the locale is chosen at runtime, so nothing is provably dead.
The one-line fix in l10n.yaml
Add the flag to your l10n.yaml:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-dir: lib/l10n/generated
synthetic-package: false
use-deferred-loading: true
(If your config still says synthetic-package: true, note that synthetic packages have been removed in current Flutter — see our l10n.yaml configuration guide for a modern baseline.)
Then regenerate:
flutter gen-l10n
No call-site changes are needed. AppLocalizations.of(context), your delegates, and supportedLocales all keep working, because the asynchrony is absorbed by the localization delegate, which already returns a Future from its load() method.
What the generated code looks like
With the flag on, gen-l10n switches every locale import to Dart's deferred as syntax and loads the library on demand. The generated lookup looks essentially like this:
import 'app_localizations_es.dart' deferred as app_localizations_es;
Future<AppLocalizations> lookupAppLocalizations(Locale locale) {
switch (locale.languageCode) {
case 'es':
return app_localizations_es
.loadLibrary()
.then((_) => app_localizations_es.AppLocalizationsEs());
// ... one case per supported locale
}
}
deferred as tells dart2js to compile that library into a separate part file instead of main.dart.js. At runtime, loadLibrary() fetches the part file over the network the first time that locale is requested, then completes. Flutter's LocalizationsDelegate.load() contract is already Future-based, so the framework simply waits for the locale's strings before building the localized subtree.
On non-web platforms the same generated code still works — loadLibrary() completes immediately (effectively a SynchronousFuture), because ahead-of-time-compiled mobile and desktop binaries have no part files to fetch.
Why this is web-only
Deferred loading with real code splitting is a dart2js compiler feature. When dart2js sees deferred as, it emits your app as main.dart.js plus numbered part files like main.dart.js_1.part.js, one chunk per deferred load unit. Mobile and desktop AOT builds don't split this way, so the flag is a no-op there — it neither helps nor hurts your APK or IPA size. (Android has a separate, unrelated mechanism: deferred components via Play Feature Delivery.)
One current caveat: this applies to the default JavaScript output. For WebAssembly builds (flutter build web --wasm), deferred-loading code splitting is still experimental behind --enable-wasm-deferred-loading; without it, dart2wasm doesn't split deferred imports the way dart2js does.
When the flag backfires
Deferred loading trades bundle bytes for network round trips. That trade is only worth it when the bytes are substantial:
- Few locales, small ARB files. With two or three languages, the strings might be a few kilobytes. Splitting them out saves almost nothing but adds an extra HTTP request — and the locale's part file blocks rendering of localized text until it arrives. Flutter's own docs note that for projects with a small number of locales the difference is negligible and can slow down startup versus bundling. Measure before and after.
- Deploy-time version skew. Part files are fetched lazily, so if a user loads
main.dart.js, you deploy a new build, and then their app requests a locale chunk, the old part file may be gone — andloadLibrary()throws aDeferredLoadException. We covered diagnosing and hardening against that failure mode in Fix DeferredLoadException & Cut Flutter Web Bundles. - Offline-first PWAs. If your service worker precaches everything anyway, the split saves parse time but not downloads — decide which metric you actually care about.
Rule of thumb: the flag pays off once you have roughly ten or more locales, or unusually large message catalogs. The Flutter Gallery, at 70+ locales, was the ideal case.
Verify the split in DevTools
Don't take the flag on faith — check the output.
1. Inspect the build directory.
flutter build web --release
ls -lh build/web/main.dart.js*
You should see main.dart.js shrink and a series of main.dart.js_N.part.js files appear — those are your deferred locales (dart2js may group other deferred imports into the same numbered chunks).
2. Watch the network. Serve build/web, open Chrome DevTools → Network tab, and reload. main.dart.js loads up front; the part file for the active locale loads afterward, on demand. Switch your app's locale at runtime and you'll see another *.part.js request fire exactly when loadLibrary() runs for the new language.
3. Compare totals. Note the transferred size of main.dart.js before and after enabling the flag. That delta — not the sum of all part files — is your first-paint win.
While you're auditing web payloads, remember that fonts usually dwarf strings: if your locales include CJK or Arabic, the fallback fonts Flutter fetches can cost megabytes and briefly render □□□ placeholders. We dug into that in Fix Flutter Web Tofu Boxes for Chinese, Japanese, Arabic — pair that fix with deferred locales for a much lighter multilingual web app.
Keep the ARB side healthy as locales multiply
Deferred loading makes it cheap to support many locales on the web — which usually means you'll add more. Every new app_<locale>.arb file is another place for a missing key or a broken ICU plural to hide, and with lazy loading those bugs only surface when a real user in that locale downloads the chunk.
That's the workflow FlutterLocalisation is built for: an ARB editor that lets you edit app_<locale>.arb files in a proper UI instead of raw JSON, manage translations across all your locales in one place, and validate ICU plural syntax — flagging locales that silently dropped a few or many category that Arabic, Polish, or Russian actually needs. Catching that before you ship beats debugging it from a lazily-loaded part file in production.
There's a free tier, so you can point it at your existing ARB directory today: Try FlutterLocalisation free.