← Back to Blog

Fix DeferredLoadException & Cut Flutter Web Bundles

flutter weblocalizationdeferred loadingbundle sizei18n

Fix DeferredLoadException & Cut Flutter Web Bundles

Your Flutter web app runs fine in debug. Then you run flutter build web --release, deploy, switch the app to Spanish, and the screen dies with:

Uncaught (in promise) Error: DeferredLoadException: Loading main.dart.js_1.part.js failed.

This is the tax for shipping every locale's strings in your first JavaScript payload — and, ironically, it's also what you hit when you finally try to stop doing that. Here's how to lazy-load your ARB locales with one l10n.yaml flag, and how to kill the DeferredLoadException that only ever shows up in release.

What --use-deferred-loading actually does

By default, gen-l10n imports every generated locale class directly. That means AppLocalizationsEn, AppLocalizationsEs, AppLocalizationsAr, and every other language you support are compiled into the initial main.dart.js. Twenty locales with a few hundred strings each is real weight in the file the browser must download before your app paints.

use-deferred-loading flips those imports to Dart deferred imports. Each locale becomes its own JavaScript chunk (main.dart.js_1.part.js, main.dart.js_2.part.js, …) that the browser only fetches when that locale is actually requested. In Flutter's own Gallery app, deferring localization strings cut the initial JavaScript bundle roughly in half.

Two caveats before you turn it on:

  • It's web-only. Deferred loading is a dart2js/dart2wasm feature; the flag is a no-op on mobile and desktop.
  • It pays off when you have many locales or many strings. With two or three languages the extra round trip can cost more than it saves.

Turn it on

Add the flag to l10n.yaml at your project root:

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
use-deferred-loading: true

Then regenerate:

flutter gen-l10n

(If you have flutter: generate: true in pubspec.yaml, the files also regenerate on every build.) Nothing else in your app code changes — you still call AppLocalizations.of(context)!.someString exactly as before.

What the generated code changes to

This is the part most tutorials skip, and it's the key to understanding the exception. Without deferred loading, the generated delegate resolves a locale synchronously:

// generated — use-deferred-loading: false
import 'app_localizations_en.dart';
import 'app_localizations_es.dart';

class _AppLocalizationsDelegate
    extends LocalizationsDelegate<AppLocalizations> {
  @override
  Future<AppLocalizations> load(Locale locale) {
    return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
  }
}

AppLocalizations lookupAppLocalizations(Locale locale) {
  switch (locale.languageCode) {
    case 'en': return AppLocalizationsEn();
    case 'es': return AppLocalizationsEs();
  }
  throw FlutterError('Unsupported locale');
}

With the flag on, the imports become deferred as, and the lookup returns a real Future that first pulls the chunk over the network:

// generated — use-deferred-loading: true
import 'app_localizations_en.dart' deferred as app_localizations_en;
import 'app_localizations_es.dart' deferred as app_localizations_es;

Future<AppLocalizations> lookupAppLocalizations(Locale locale) {
  switch (locale.languageCode) {
    case 'en':
      return app_localizations_en.loadLibrary()
          .then((_) => app_localizations_en.AppLocalizationsEn());
    case 'es':
      return app_localizations_es.loadLibrary()
          .then((_) => app_localizations_es.AppLocalizationsEs());
  }
  throw FlutterError('Unsupported locale');
}

Notice gen-l10n already calls loadLibrary() for you — you do not write it by hand. The framework's Localizations widget awaits that future and shows nothing localized until it resolves. Which is exactly where things can break.

Why DeferredLoadException only appears in release

In debug (flutter run -d chrome) Flutter compiles with DDC and doesn't split those .part.js files the same way — the locale code is already resident, so loadLibrary() resolves instantly and never touches the network. The separate chunk files only exist after flutter build web. So the one thing that can fail — fetching a chunk over HTTP — simply doesn't exist until you make a release build. That's why it "works on my machine" and dies in production.

DeferredLoadException means one of those .part.js files couldn't be loaded. There are two real causes.

Cause 1: the chunk isn't actually served

The request for main.dart.js_1.part.js has to return JavaScript. On many SPA hosts a catch-all rewrite sends everything to index.html, so the browser gets HTML where it expected JS and the load fails. A wrong <base href> (common when deploying under a subpath) does the same thing — relative chunk URLs resolve to 404s.

Check it directly:

# Should return JavaScript, NOT text/html
curl -sI https://yourapp.com/main.dart.js_1.part.js | grep -i content-type

Fixes: make sure your fallback serves real files first (try_files $uri $uri/ /index.html; on Nginx does this correctly), and build with the right base path when you deploy to a subdirectory:

flutter build web --release --base-href /myapp/

Cause 2: stale chunks after a redeploy

When you redeploy, the browser (or the Flutter service worker) may hand back a new main.dart.js while still serving an old, cached .part.js — or vice versa. The APIs don't match and you get DeferredLoadException. Fix it by making sure entrypoints can't be served stale:

# Cache-Control on the hosting layer
main.dart.js                no-cache
flutter_service_worker.js   no-cache
main.dart.js_*.part.js      no-cache   # or version the whole deploy folder

The most bullet-proof option is to deploy each release into its own hashed/versioned directory so URLs change every time and nothing stale can be reused.

The load pattern worth adding

Because the delegate load is now genuinely async, two things are worth handling yourself. First, preload the active locale before runApp (or before a locale switch) so users don't see a flash of unlocalized UI:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  try {
    await AppLocalizations.delegate.load(const Locale('es'));
  } on DeferredLoadException {
    // chunk fetch failed — recovered by the guard below
  }
  runApp(const MyApp());
}

Second, add a global guard that recovers from a bad or stale chunk by hard-reloading once, which pulls a fresh, consistent set of files:

import 'package:web/web.dart' as web;

void bootstrap() {
  runZonedGuarded(() {
    runApp(const MyApp());
  }, (error, stack) {
    if (error is DeferredLoadException) {
      web.window.location.reload();   // fetch fresh main.dart.js + parts
    } else {
      // your normal error reporting
    }
  });
}

Verify the win

Build and confirm the split actually happened:

flutter build web --release
ls -lh build/web/*.part.js

You should see one chunk per deferred locale and a noticeably smaller main.dart.js. Load the app with a throttled network in DevTools and watch the locale chunk fetch only when you switch languages — that deferred request is the bundle savings, made visible.

Ship it faster with FlutterLocalisation

Deferred loading rewards you for keeping your ARB files clean and your locale set intentional. FlutterLocalisation gives you a visual ARB editor to manage strings across every language without hand-editing JSON, and our l10n.yaml configuration guide covers every flag in depth. See pricing and try FlutterLocalisation free to keep your web bundle lean as your language list grows.