How I moved 98 Flutter tools and 32 games out of main.dart.js with deferred imports and one parametric route: 1.92MB to 1.08MB brotli, and what stayed put.
My portfolio carries 98 interactive tools and 32 browser games. All client-side, all free, all hosted on Firebase for exactly $0 a month. That's a nice pitch right up until you look at what a first-time visitor downloads in order to read one blog post: main.dart.js at 8.4MB uncompressed, 1.92MB over the wire after brotli. Every hex-to-RGB converter, the entire PDF generation stack, a Sudoku solver — all of it, shipped to somebody who came to read about Riverpod and will never open a single tool.
dart2js isn't doing this to spite you. It's a whole-program compiler: it walks every path reachable from main(), tree-shakes what nothing touches, and emits one JavaScript file. If your router mentions a widget, that widget is reachable, and reachable means shipped. With 130 pages wired into one GoRouter, "reachable" means everything.
The fix is Dart's deferred loading — import ... deferred as, then loadLibrary() — and it took my initial bundle from 1.92MB to 1.08MB brotli, a 44% cut, with tools and games moved into chunks that download only when someone actually opens one. This post is the whole change as I shipped it: how I decided what to defer, why the obvious router structure quietly defeats splitting, the registry + DeferredPage pattern that made it work, how to measure with numbers that mean something, and — honestly — the large parts of a Flutter web payload that deferred imports cannot touch at all.
Before optimising anything, take the real inventory. Here is what build/web looks like on my site today, measured with brotli -q 11 because brotli is what Firebase Hosting negotiates with any modern browser:
main.dart.js 4.98 MB raw 1.09 MB brotlimain.dart.js_1.part.js 3.53 MB raw 783 KB brotli (tools)main.dart.js_3.part.js 310 KB raw 82 KB brotli (games)main.dart.js_2.part.js 12 KB raw 4.5 KB brotli (shared)canvaskit/canvaskit.wasm 7.28 MB raw 2.26 MB brotliflutter_bootstrap.js 13 KB raw 4.5 KB brotli
Two things jump out. First, the Dart code was never the biggest number on the page — CanvasKit is, by more than two to one, and I'll come back to that because it changes how much celebrating you're entitled to do. Second, that tools chunk is 783KB brotli on its own. That is the thing I used to hand to every blog reader, every job-application visitor, every person who landed on the homepage from a Google result and bounced in four seconds.
The distinction that matters here is between raw and transferred. A lot of Flutter bundle-size posts quote the uncompressed figure because it's the one ls -la gives you, and it makes for a scarier headline. But nobody downloads 4.98MB of main.dart.js. Minified JavaScript is enormously compressible — it's mostly repeated identifiers and structural boilerplate — and brotli routinely gets 4–5x on it. Optimise for the number the user's connection actually carries, or you'll spend a day chasing a saving the wire was already giving you for free.
Deferred loading is not free. Every deferred boundary costs you an extra network round trip at the moment the user navigates, and a loading state you now have to design. So the decision is a ratio: how much code moves out, divided by how likely the average visitor is to need it.
My rules, in the order I apply them:
pdf, printing, image, encrypt, qr_flutter and 98 page widgets. Maybe 15% of sessions open a tool. That's the easiest call I've ever made.CustomPainter. Tools are 98 pages and 783KB because six heavyweight packages ride along with them. The package graph, not the file count, is what you're actually splitting.There's a further candidate people forget: route-level splits are the natural seam, but they aren't the only one. A single page with an expensive optional mode — an export-to-PDF dialog, a chart library used on one tab — can defer that mode alone. I haven't needed it yet, but the mechanism is identical.
This is the part that cost me the most time, and it's the reason a lot of people try deferred imports in Flutter web and conclude they "don't work".
My router had 98 entries that looked like this:
GoRoute(path: '/tools/json-formatter', builder: (_, __) => const JsonFormatterPage()),GoRoute(path: '/tools/base64', builder: (_, __) => const Base64Page()),// ...96 more
That is a static reference to every page class, sitting in router.dart, which is in the main output unit because main() reaches it immediately. It doesn't matter what else you do. You can add deferred as on the registry, wire up loadLibrary(), ship the whole ceremony — and dart2js will still put every tool in main.dart.js, because deferred loading in dart2js works by computing, for each deferred import, the set of code reachable only through that import. Anything reachable from the main unit by any other path gets hoisted back into the main unit. One static reference is enough to undo the split for that class, and 98 static references undo it entirely.
So the split isn't really a compiler flag, it's a refactor: there must be exactly one path to that code, and it must go through the deferred prefix. In practice that means collapsing the static routes into a parametric one:
GoRoute( path: '/tools/:slug', builder: (context, state) { final slug = state.pathParameters['slug']!; return DeferredPage( load: tools_lib.loadLibrary, builder: () => tools_lib.kToolBuilders[slug]?.call(context) ?? const NotFoundPage(), ); },),router.dart lost 519 lines in that commit and gained the only property that matters: it no longer names a single tool widget.
The same trap catches you in subtler places. Analytics enums that reference page types, a switch over routes for breadcrumb titles, a "related tools" rail that constructs widgets rather than reading metadata — each of those is a rope tying the code back to the main unit. If your chunk comes out suspiciously small, go looking for the rope.
The whole pattern is three small pieces.
tools_registry.dart imports all 98 pages and exposes a single map. It is the only file in the project that imports a tool page.
/// slug -> builder for every interactive tool. Imported `deferred` by the/// router so all tool widgets land in their own JS chunk, loaded on demand.final Map<String, WidgetBuilder> kToolBuilders = { 'json-formatter': (_) => const JsonFormatterPage(), 'base64': (_) => const Base64Page(), 'chmod-calculator': (_) => const ChmodCalculatorPage(), // ...95 more};games_registry.dart is the identical shape with kGameBuilders. Two registries, two chunks — worth keeping separate, because a visitor who came for Tetris has no reason to download the PDF stack.
import 'package:my_portfolio/pages/games/interactive/games_registry.dart' deferred as games_lib;import 'package:my_portfolio/pages/tools/interactive/tools_registry.dart' deferred as tools_lib;
Nothing about deferred as is magic; it's an instruction to the compiler that everything reached exclusively through this prefix belongs in its own output unit. Touching tools_lib.kToolBuilders before loadLibrary() completes throws — the analyzer won't always warn you, so keep the access site inside the guard.
class DeferredPage extends StatefulWidget { final Future<void> Function() load; final Widget Function() builder; const DeferredPage({super.key, required this.load, required this.builder}); @override State<DeferredPage> createState() => _DeferredPageState();}class _DeferredPageState extends State<DeferredPage> { late final _future = widget.load(); @override Widget build(BuildContext context) => 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(); }, );}Three details in there are load-bearing and I got each of them wrong once.
Start the future in a field initialiser, not in build. late final _future = widget.load() runs once. Calling widget.load() inside build restarts it on every rebuild, and since loadLibrary() returns a cached future after the first call it looks fine — until you hit a rebuild during the download and watch the spinner reset.
Render the site shell while loading, not a bare spinner. The nav bar, footer and page frame are already in the main bundle. Painting them immediately and filling only the content area means the deferred boundary reads as a page loading, not as the app hanging.
Handle the error case, and mean it. loadLibrary() rejects on a flaky connection or on a stale chunk after a deploy. Without a catch you get a permanently blank route and no console message a normal user would ever see. A retry-able error state is the difference between "the internet blipped" and "your site is broken".
My /games browse page lists all 32 games with names, descriptions, tags and icons. My /tools page does the same for 98 tools. Those pages are in the main bundle by design — they're the landing pages the split is supposed to protect.
Which creates an obvious problem: if the metadata list and the widget builders live in the same file, listing a game drags every game's code back into the main unit. So builtin_games.dart became metadata-only:
/// A playable game in the arcade. Pure metadata — the slug->widget wiring/// lives in `games_registry.dart` (imported `deferred` by the router) so this/// list can feed the Firestore seed, browse page and related-games rail/// WITHOUT pulling every game into the main bundle.class BuiltInGame { final String name; final String description; final String slug; // ...no WidgetBuilder anywhere in here}The split is metadata in main, behaviour in the chunk, and the two are joined only by a string slug. It's a slightly weaker contract than a compile-time map — a typo'd slug becomes a 404 at runtime instead of an analyzer error — so I keep a test that asserts every kBuiltInGames slug has a matching key in kGameBuilders. That's ten lines of test to buy back the safety I gave up, and it has caught me twice.
The build itself is unchanged: flutter build web --release. dart2js emits main.dart.js plus one main.dart.js_N.part.js per output unit, and the numbering is an implementation detail — don't hardcode it anywhere.
Measure like this:
cd build/webfor f in main.dart.js *.part.js; do printf '%-28s raw=%-9s br=%s\n' "$f" \ "$(stat -f%z "$f")" "$(brotli -c -q 11 "$f" | wc -c | tr -d ' ')"done
Record the before number before you start. I nearly didn't, and this entire post would have been "it feels faster now".
Then verify the split actually happened, because a passing build proves nothing. The authoritative check is the network tab: load /blog, confirm no .part.js is requested; navigate to /tools/chmod-calculator, watch main.dart.js_1.part.js arrive at exactly that moment.
For a quick terminal smoke test, grep the release output for a string literal that only that page can produce:
grep -c -F "Recursive (-R)" main.dart.js # 0 — goodgrep -c -F "Recursive (-R)" main.dart.js_1.part.js # 1 — good
Note string literal, not class name. grep JsonFormatterPage main.dart.js returns zero whether or not the split worked, because release builds minify every identifier. String literals survive minification; symbol names don't. That one had me celebrating nothing for a solid ten minutes.
One asymmetry worth expecting: a few strings legitimately remain in both. "Sudoku" appears in main.dart.js twice — that's the browse-page metadata — and three times in the games chunk, which is the actual game. Metadata in main, behaviour in the chunk, exactly as designed.
Here's the part I'd want to read if I were deciding whether this is worth a day.
CanvasKit dominates and there is nothing to split. canvaskit.wasm is 2.26MB brotli. The 840KB of Dart I moved out is real, but it's a third of a payload that still has to fetch a WebAssembly renderer before it can paint anything. The skwasm variant is 1.21MB brotli, which is better, and the HTML renderer avoids the wasm entirely at the cost of text and graphics fidelity I'm not willing to give up on a portfolio. So the honest framing of my 44% is: I cut the part of the payload I control by nearly half, and the part I don't control is still the largest thing on the page. The mitigation that actually helps is caching — CanvasKit is versioned and its URL is content-stable, so it's a first-visit cost, not a per-visit one.
The admin ShellRoute is still in there. Fourteen editor pages, statically imported in router.dart, riding along in every public visitor's bundle. It's the single best remaining candidate and I haven't done it, because the auth guard runs a redirect before the route builds, and threading loadLibrary() through a ShellRoute builder plus a refreshListenable needs more care than the tools split did. I'd rather say that plainly than pretend the job is finished.
Deferred loading is web-only. On mobile and desktop AOT builds, loadLibrary() resolves immediately and deferred as is effectively a no-op. If you share a codebase across platforms the pattern is harmless everywhere, but the payoff exists only on web.
Everything the framework needs is already in main. Flutter's own rendering, Material, go_router, cloud_firestore — that's your floor. Deferring application code doesn't move the floor.
The chunks are useless if the browser refetches them constantly, so the hosting config matters. Mine, in firebase.json:
{ "source": "/index.html", "headers": [{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }] },{ "source": "**/*.@(woff2|otf|ttf|wasm)", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] }index.html must never be cached — it's the entry point that names the current build. Fonts and canvaskit.wasm get a year and immutable, because their URLs are content-stable: a given CanvasKit version's bytes never change.
What I deliberately do not set is immutable on the JS. Flutter names its output main.dart.js and main.dart.js_1.part.js with no content hash in the filename. If I cached those for a year, a visitor with a warm cache would get yesterday's main.dart.js_1.part.js against today's main.dart.js after a deploy, and the chunk would fail to resolve against a renamed symbol table. Flutter's generated service worker handles versioning through flutter_service_worker.js and a resource hash map, which is exactly why that file is served no-cache too — it's the thing that tells the browser everything else changed.
If you're not on Firebase Hosting, the two must-haves are the same: brotli negotiation on .js and .wasm, and a short or revalidated cache on unhashed JS. And check that your CDN actually serves brotli rather than gzip on those extensions — gzip on my main.dart.js is 1.41MB against brotli's 1.09MB, so a misconfigured content-encoding quietly gives back a third of the work described in this post.
GoRoute entries are the most common thing that silently defeats deferred loading; collapse them into one parametric /tools/:slug route resolved through a deferred as registry map.WidgetBuilders — keep the metadata list in the main bundle and join it to the deferred registry by string.immutable — main.dart.js_1.part.js keeps its name across deploys, and a stale chunk against a fresh main bundle is a broken route with no error message.The whole change was one afternoon: two registry files, one DeferredPage widget, 519 lines deleted from the router. What I'd tell anyone about to do it is that the compiler is honest — if your chunk comes out at 4KB, you have a static reference you haven't found yet, and no amount of loadLibrary() will fix it. Find the rope, cut it, and measure the wire.