Dart deferred imports compile fine and often split nothing. The eager import rule, loadLibrary retries, a flash free DeferredPage, and verifying from build output.
My portfolio site carries 98 browser tools and 32 browser games. They're all Flutter, they all run entirely client-side, and until recently every one of them shipped inside the same main.dart.js. Someone who landed on the site to read a blog post was downloading a JSON-to-Dart model generator, an AES tool, a cron parser and a full European roulette table before the framework painted its first frame. Roughly 8.8MB of JavaScript, about 2.5MB over the wire gzipped, to render a list of article cards.
Dart has had the fix built in for years. Two lines: import '...' deferred as x; and await x.loadLibrary(). What nobody warns you about is that those two lines are among the easiest in the language to write correctly and have do absolutely nothing. The code compiles. flutter analyze is clean. The app runs, the page loads, the feature works. And the compiler has quietly decided your deferred library wasn't deferrable after all, folded every byte of it back into the main bundle, and emitted a 4KB stub part file that you will never think to open.
Deferred loading is the only optimisation I've shipped where the failure mode is total silence. So this post is deferred imports the way I actually use them: what the compiler can and cannot split, the single rule that decides whether the split happens, why constants and top-level references leak, the DeferredPage widget I ship — loader flash and retry path included — and how to prove from the build artifacts that it worked.
The syntax is small. You add deferred as <prefix> to an import, and from then on nothing behind that prefix exists until you've awaited loadLibrary().
import 'package:my_portfolio/pages/games/interactive/games_registry.dart' deferred as games_lib;// Later, before you touch anything on games_lib:await games_lib.loadLibrary();final builder = games_lib.kGameBuilders['roulette'];
That's the whole API. loadLibrary() returns Future<void>, it's generated on every deferred prefix, and its job is to make the code behind the prefix available. Touch games_lib.anything before that future completes and you get a runtime error, not a compile error — the analyzer warns you in the obvious cases, but it can't chase every call path.
The cost isn't syntax, it's a change in the shape of your app. A deferred boundary is an asynchronous boundary: everything reading across it needs a loading state, an error state, and a story for what happens if the user navigates away mid-load. In a router that's tidy, because a route is already a place you expect a transition. Anywhere else it gets ugly fast, which is why I only put deferred boundaries at route builders.
Worth knowing which platforms honour this. On Flutter web compiled with dart2js, deferred imports produce genuinely separate JavaScript files fetched on demand — that's the case this post is about. On iOS, loadLibrary() completes immediately and everything sits in the one AOT binary. Android has Deferred Components backed by Play Feature Delivery, an entirely different machine. And in a web debug build the code compiles to modules rather than dart2js output units, so loadLibrary() resolves without the download you're trying to create. You cannot verify a code split by running flutter run -d chrome.
The mental model that fixed this for me is that dart2js isn't splitting files. It's partitioning reachable program elements into output units.
The compiler starts at your entrypoint and walks the reachability graph — every class, method, field and constant that the program can actually get to. For each element it asks one question: which deferred imports must you pass through to reach this? Elements reachable without going through any deferred prefix land in the main output unit. Elements reachable only through exactly one deferred prefix get their own output unit — that's your .part.js file. Elements reachable only through deferred code but via two or more different prefixes go into a shared unit that's fetched when either one loads.
Three consequences fall out of that, and they set your expectations correctly:
main.dart reaches Material, the render tree, Firebase, your theme and your shell without touching a deferred prefix. All of it stays in the main unit, forever.deferred as. It cares whether anything else can already get there.Here's what that looked like in practice on my site after splitting tools and games out:
| Artifact | Raw | Gzipped |
| --- | --- | --- |
| main.dart.js | 4.98MB | 1.43MB |
| main.dart.js_1.part.js (tools) | 3.53MB | 1.10MB |
| main.dart.js_3.part.js (games) | 310KB | 101KB |
The main bundle is still 4.98MB raw and always will be — that's Flutter plus Firebase plus the shell. What changed is that 3.8MB of my code left the critical path. A reader who came for a blog post now downloads 1.43MB gzipped instead of ~2.5MB; someone who actually wants roulette pays an extra 101KB, once.
Everything above collapses into one operational rule, and it's the rule people break.
If any library reachable from your entrypoint imports the deferred library with a normal import, every element in it becomes reachable from main and lands in the main output unit. Your deferred prefix still compiles, loadLibrary() still works, the part file is still emitted — it's just nearly empty, because nothing was left that only deferred code could reach.
There is no warning for this. Not from the compiler, not from flutter analyze, not from dart analyze --fatal-infos. It is the single most common reason code splitting "doesn't work", and the only way you find out is by reading the build output.
The trap in my own repo was sitting right there. There are two tools routes: /tools, which lists all 98, and /tools/:slug, which renders one. The list page is on the eager path — it's in the nav. If tools_page.dart had imported tools_registry.dart for card names and icons, that one import would have dragged all 98 tool implementations into the main bundle and made the deferred import on the detail route pointless.
The fix is to split metadata from implementation:
// tools_page.dart — eager. Imports the data model only.import 'package:my_portfolio/models/tool.dart';// router.dart — the ONLY file that touches the registry.import 'package:my_portfolio/pages/tools/interactive/tools_registry.dart' deferred as tools_lib;
Tool is a small Firestore-backed model: slug, title, category, icon name, description. It's cheap and it's eager. tools_registry.dart is a single file whose entire content is a Map<String, Widget Function(BuildContext)> and the 98 imports that populate it. Nothing else in the app imports it. That property — exactly one importer, and that importer uses deferred as — is the invariant the whole optimisation rests on.
Barrel files are how this invariant usually dies. Add lib/tools/tools.dart that re-exports everything, have one eagerly-loaded file import the barrel for a single symbol, and the split is gone. Same with a service locator that registers all your pages at startup, or a switch in an analytics helper mapping a runtime type to a screen name.
The other leak is subtler because it doesn't look like an import at all.
Constant expressions are the case the analyzer does catch: it's an error to reference a deferred prefix from a const context, and you'll get a diagnostic about a constant referencing a deferred library. Fine. Annoying, but loud.
What's quiet is everything that forces a class to exist for the main unit without importing its library directly: a top-level or static final route table in an eager file that mentions the widget types; an is / as check or a switch on a runtime type against a class behind the boundary; a type argument that has to be reified, like List<RoulettePage> in an eager signature.
The unifying idea is the reachability graph again. If main can name it, main owns it. A deferred boundary works when the eager side knows the deferred side only through strings and plain data — a slug, a title, a category — and never through types.
That's why my registry is keyed by String and yields a Widget Function(BuildContext) rather than exposing concrete page classes:
// tools_registry.dart — deferred side. Strings in, generic builders out.final Map<String, Widget Function(BuildContext)> kToolBuilders = { 'json-formatter': (_) => const JsonFormatterPage(), 'jwt-decoder': (_) => const JwtDecoderPage(), // ...96 more};The eager router has a String slug from the URL and gets back a Widget. It never names a single tool class. That's not stylistic preference — it's the thing that keeps 3.5MB out of the main bundle.
loadLibrary() is idempotent, so stop memoising itA lot of the deferred-loading code I read is wrapped in defensive bookkeeping — a bool _loaded, a Completer, a Future? cached in a singleton — to avoid "loading twice". You don't need any of it. The first call injects a script tag and fetches the part file; every subsequent call returns an already-complete future with no network round trip, and concurrent calls made while a load is in flight are deduped by the runtime. loadLibrary() on a loaded prefix is effectively free.
That changes how you can use it. Two patterns I lean on:
Call it unconditionally from the route builder. Every navigation to /games/:slug calls games_lib.loadLibrary(). First navigation downloads; the next fifty don't. No caching layer, no if (!_loaded).
Prefetch on intent, not on load. The /games list page is the place to warm the chunk: someone scrolling a list of games is very likely to open one, and they're spending a few seconds reading while the network sits idle.
@overridevoid initState() { super.initState(); // Warm the games chunk while the user browses the list. // Fire-and-forget: the route builder awaits it properly. games_lib.loadLibrary().ignore();}By the time they click a card the chunk is in memory and the detail page renders in the same frame — the bandwidth win of splitting with the responsiveness of not splitting. The rule I keep is that prefetching must never happen on a route that's on the first-paint path. Prefetching from the homepage puts the download back in the critical window and undoes the whole exercise.
Here's the widget the whole thing hangs off. It takes a load callback (a loadLibrary tear-off) and a builder that runs after the load resolves.
class _DeferredPageState extends State<DeferredPage> { late final Future<void> _future = widget.load(); @override Widget build(BuildContext context) { return FutureBuilder<void>( future: _future, builder: (context, snap) { if (snap.connectionState != ConnectionState.done) { return const SiteShell(child: LoadingState(minHeight: 420)); } if (snap.hasError) { return const SiteShell( child: EmptyState( icon: Icons.wifi_off_rounded, title: "Couldn't load this page", message: 'Check your connection and refresh to try again.', ), ); } return widget.builder(); }, ); }}And it's wired in at the route:
GoRoute( path: '/games/:slug', builder: (context, state) { final slug = state.pathParameters['slug']!; return DeferredPage( load: games_lib.loadLibrary, builder: () => games_lib.kGameBuilders[slug]?.call(context) ?? const NotFoundPage(), ); },),Two details in there matter more than they look.
The future lives in state, not in build(). FutureBuilder(future: widget.load()) written inline creates a new future on every rebuild, which resets the snapshot to waiting every time anything above it rebuilds — a theme change, a media query, a hover. You get a loader that flickers back in for no reason. late final Future<void> _future = widget.load(); creates it exactly once for the life of the state object.
The loader renders inside the site shell. SiteShell — nav, footer, background — is already in the main bundle, so keeping it mounted costs nothing and the page never blanks; only the content area swaps. A full-screen spinner for a 200ms chunk fetch makes an in-app navigation feel like a browser reload, which is precisely the impression you were avoiding.
There's a flaw in the widget above, and it shows up the second time a user opens a tool. FutureBuilder's first snapshot is always ConnectionState.waiting, even when the future it's handed is already complete. So on a warm chunk you paint the spinner for exactly one frame, then the real page. That single-frame flash reads as a glitch — worse than showing nothing.
I fix it with two changes that stack. First, track loaded-ness synchronously — keyed by a token identifying the chunk — and skip FutureBuilder entirely when there's nothing to wait for:
final _loaded = <Object>{};late final Future<void>? _future = _loaded.contains(widget.token) ? null : widget.load().then((_) => _loaded.add(widget.token));@overrideWidget build(BuildContext context) { if (_future == null) return widget.builder(); // warm: render immediately return FutureBuilder<void>(future: _future, builder: /* as above */);}Second, delay the spinner on the genuine first load. If the CDN is warm the fetch can land well under 150ms, and a spinner shown for 80ms is worse than showing the previous frame for 80ms. Gate the loading widget behind a Future.delayed(const Duration(milliseconds: 150)) — before it elapses you render nothing or a static skeleton, after it you render the spinner. Fast loads never show one; slow loads get honest feedback.
This is the part I see skipped most often, and it produces the worst user-facing symptom in the whole feature.
Part files are ordinary JavaScript files fetched over the network at navigation time. They aren't bundled into anything, they aren't precached, and they're subject to every failure a network request has: a dead connection, a captive portal, a CDN blip, a proxy that mangles the response. When that fetch fails, the future from loadLibrary() completes with an error — dart2js surfaces it as a DeferredLoadException.
If you don't handle snapshot.hasError, here's what the user gets: they tap a game, they watch a spinner, and the spinner spins forever. No message, no retry, no indication anything is wrong. A route behind a deferred import with no error branch is a route that hangs on any bad network. That's not a rare edge case on mobile — it's a Tuesday on a train.
A failed load doesn't permanently poison the prefix. Calling loadLibrary() again re-attempts the fetch, which is what makes a Retry button possible at all. But notice that the late final Future trick that killed the rebuild flicker also makes retry impossible, because you can never construct a second future. The fix is an attempt counter:
int _attempt = 0;late Future<void> _future = widget.load();void _retry() => setState(() { _attempt++; _future = widget.load();});// In the error branch, keyed so FutureBuilder picks up the new future:FutureBuilder<void>( key: ValueKey(_attempt), future: _future, // ... error branch renders EmptyState with an onRetry: _retry action)Don't loop it automatically. Auto-retry against a genuinely offline device burns battery and produces a spinner that never resolves; one visible "Try again" button gives the user information and control. If you want automation, cap it at two attempts with a short backoff, then fall back to the button.
One caching detail that bites later: main.dart.js_1.part.js is a stable filename across builds, not a content-hashed one. Serve part files with Cache-Control: max-age=31536000, immutable and a returning visitor can end up with a fresh main.dart.js and a months-old part file — a mismatch that fails in ways which are miserable to diagnose. In my Firebase Hosting config only fonts and wasm get the immutable treatment; the JavaScript deliberately doesn't. Whatever cache policy main.dart.js gets, the part files must get the same one.
Everything above is theory until you look at the artifacts. This takes ninety seconds and it's the only step that tells you the truth.
flutter build web --releasels -la build/web/main.dart.js build/web/*.part.js
Three outcomes, and each means something specific:
.part.js files at all. Nothing was deferred — either the prefix isn't on a path the compiler can reach, or you're looking at a debug build.Part files are numbered, not named, so the next question is which is which. Minification renames identifiers, but string literals survive it — route slugs, labels, error text. Grep for one only that feature could contain:
grep -c blackjack build/web/main.dart.js_3.part.js # 3grep -c blackjack build/web/main.dart.js # 0 ← the actual proof
The second command is the one that matters. A zero there is hard evidence the games code is not in the main bundle. If both numbers are non-zero, the split leaked.
Then check the number the browser actually pays — the compressed size, not the raw one — with gzip -c "$f" | wc -c over each artifact.
Finally, confirm runtime behaviour in a real browser against the release build, not flutter run. Serve build/web, open DevTools' Network tab, load /, and check that only main.dart.js comes down. Navigate to /games/roulette and a separate .part.js request should appear. Navigate away and back and there should be no second request — that's your idempotency proof, observed rather than assumed. Then throttle to Offline and hit a deferred route to exercise the error branch you just wrote.
One caveat: if you compile with --wasm you're on dart2wasm, the emitted artifacts are different, and the output-unit behaviour is not the same story. Re-verify from scratch rather than porting a conclusion you reached about JavaScript output.
main() collapses back into the main bundle, silently and with no compiler or analyzer warning — the number one reason code splitting appears to do nothing.loadLibrary() is idempotent and free after the first call, so call it unconditionally from route builders and prefetch it from list pages — but never from a route on the first-paint path.build(), and skip FutureBuilder entirely for an already-loaded chunk so warm navigations don't flash a one-frame spinner.DeferredLoadException, offer a manual retry that builds a fresh future, and don't retry in a loop.main.dart.js or a returning visitor will eventually mix a new main bundle with a stale chunk.ls the part files, grep the main bundle for a string that should have moved out, and watch the Network tab on a served release build.Deferred loading is unusual in that writing it and getting it right are almost unrelated skills. The syntax takes a minute. The discipline — one file behind the boundary, strings across it, no eager importer, a real error path, a grep against the built bundle before you call it done — is the actual work. Build it, then go read build/web and prove it to yourself, because the one thing this feature will never do is tell you it isn't working.