Skip to content
Blog

Route-Based Code Splitting for Flutter Web

Shrink the initial download of a Flutter web app with Dart's deferred imports and loadLibrary(), tie deferred loads to route entry, and know what works on dart2js versus WebAssembly.

Published on • October 29, 2026

AI Assistant

A Flutter web app ships one Dart codebase, and by default it ships it as one bundle. That is fine for a landing page and painful for a dashboard: the user who only ever opens the settings screen still downloads the analytics charting code, the PDF exporter, and every admin screen you wrote.

Route-based code splitting is the fix. You mark rarely used screens as deferred Dart libraries, then load each one the moment its route is entered — so the first paint costs a small shell instead of the whole application. Dart has built-in syntax for exactly this, and Flutter has a documented way to wire it into widgets.

In this post you will learn the deferred as / loadLibrary() mechanics, how to attach them to routes, what works today on dart2js versus WebAssembly, and how to measure whether it actually helped.

The mechanics: deferred as and loadLibrary()

Dart’s libraries documentation calls it deferred loading, also called lazy loading: it “allows a web app to load a library on demand, if and when the library is needed,” and lists three intended uses — reduce a web app’s initial startup time, A/B testing alternative implementations, and loading rarely used functionality “such as optional screens and dialogs.”

Screens are the case we care about. The syntax is two lines:

import 'package:my_app/analytics/dashboard_screen.dart' deferred as dashboard;

Future<void> openDashboard() async {
  await dashboard.loadLibrary();
  // Safe to reference dashboard.DashboardScreen from here on.
}

loadLibrary() returns a Future that completes when the code is available. You can call it repeatedly — “the library is loaded only once.” Dart implicitly inserts loadLibrary() into the namespace you define with deferred as.

Three constraints from the docs will bite you if you ignore them:

  • You can’t use types from a deferred library in the importing file. Move interface types into a library that both sides import.
  • A deferred library’s constants aren’t constant in the importing file — they don’t exist until the library loads.
  • All usage of deferred symbols must sit behind a completed loadLibrary() call, and every import of that library must be marked deferred. One ordinary import anywhere in the graph and the library is compiled into the base bundle instead of split out.

On the web, the compiler emits the deferred library as a separate file — the Flutter blog’s deferred-loading walkthrough shows main.dart.js_1.part.js arriving only when loadLibrary() runs.

Turning it into widgets

A widget’s build method must be synchronous, so you cannot await inside it. Flutter’s own deferred components guide shows the canonical wrapper: start the future in initState, then gate the tree with a FutureBuilder.

import 'package:flutter/material.dart';

import 'report_screen.dart' deferred as report;

class ReportHost extends StatefulWidget {
  const ReportHost({super.key});

  @override
  State<ReportHost> createState() => _ReportHostState();
}

class _ReportHostState extends State<ReportHost> {
  late final Future<void> _libraryFuture = report.loadLibrary();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<void>(
      future: _libraryFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.hasError) {
            return Text('Failed to load reports: ${snapshot.error}');
          }
          return const report.ReportScreen();
        }
        return const Center(child: CircularProgressIndicator());
      },
    );
  }
}

The guide adds two details worth remembering: loadLibrary() completes quickly on repeat calls (but not synchronously), and “can also be called early to trigger a pre-load to help mask the loading time” — for example, on app start or on hover/focus of the link that leads there. Flutter’s Gallery used the same technique in its deferred_widget.dart.

Tying the load to route entry

The natural seam is the route builder itself. With go_router, wrap each heavy route’s builder:

GoRoute(
  path: '/reports',
  builder: (context, state) => const ReportHost(),
),

Because the route builder only runs when the route is actually entered, the deferred chunk is fetched on navigation — not on startup. Two patterns combine well:

  • Lazy on entry — the ReportHost above. Best for deep, rarely visited screens.
  • Prefetch on intent — kick off report.loadLibrary() in a hover handler, a menu-open callback, or immediately after your shell finishes its first frame. Best for the one or two routes most users will head to next.

Keep the rule from the navigation docs in mind: only page-backed routes are deep-linkable, and declarative routers display the same screens on a cold deep link. A user who lands directly on /reports must be able to run the deferred load from a cold start, so put the loading gate in the widget — not in a navigation callback that won’t fire.

Debug, profile, release — and what actually splits

Deferred splitting is a release concern, and it behaves differently per mode. Flutter’s build modes page defines the web variants:

BuildMinifiedTree shakingCompiler
debugNoNodartdevc
profileNoYesdart2js
releaseYesYesdart2js

Flutter’s deferred components documentation notes that debug mode “treats all deferred components as regular imports” for Android; on the web, the separate-part output is a product of the optimizing dart2js build. In short: test code splitting against flutter build web, not against flutter run.

Before splitting anything, take your baseline. The deployment docs give the minification table above — tree shaking already removes code that is provably unreachable, and you can verify your release build with a plain static server:

$ flutter build web
$ python -m http.server 8000 --directory build/web

JavaScript versus WebAssembly

This is where the docs get specific, and where you should check before promising a win.

JavaScript (dart2js) — deferred imports work as described. The web creates deferred components as separate *.js files.

WebAssembly (dart2wasm) — per the Wasm support page:

Enabling Wasm compilation for a Flutter web app does not use deferred loading by default. Deferred loading in Wasm is available in Flutter 3.50 and later and can be enabled using the --enable-wasm-deferred-loading flag.

$ flutter build web --wasm --enable-wasm-deferred-loading

You can also supply a wasmDeferredModulesLoader callback in your Flutter web app initialization configuration to customize how deferred .wasm modules are downloaded at runtime.

Caching caveat. The --web-content-hash flag (Flutter 3.50+) is explicitly documented as not yet supporting deferred imports in either dart2js or dart2wasm builds. If you rely on both, plan your cache headers accordingly.

Measuring the result

Splitting you cannot measure is splitting you cannot trust.

  1. Build for release and serve build/web with a static server.
  2. Open Chrome DevTools → Network and filter on main.dart. Reload: you should see the base bundle plus a small number of .part.js (or .wasm) files — and crucially, the heavy route’s chunk should not be in the initial request list.
  3. Navigate to the heavy route and watch the chunk arrive at that moment.
  4. Compare before/after totals for the initial request set — that delta is your real-world saving.

Flutter’s DevTools Network view is excellent for HTTP traffic from Dart, but its own docs say it plainly: “For a web app that makes requests using the browser, we recommend using browser tools to inspect network traffic, such as Chrome DevTools.”

One deployment note from the web deployment docs: when you split, you have more files arriving later in the session. If you adopt --web-content-hash, keep the previous release’s hashed main.dart.* and assets/** files available for a grace window, and exclude /main.dart.* from your SPA /index.html catch-all rewrite so a genuinely missing chunk returns a 404 instead of 200 text/html.

Conclusion & Next Steps

Route-based code splitting in Flutter web is three moves: mark the heavy screen deferred as, wrap its route builder in a FutureBuilder that awaits loadLibrary(), and verify the split against a release build with the Chrome DevTools network tab. Everything else — prefetching, Wasm flags, cache hashing — is optimisation on top of that foundation.

Next Steps: pick your single heaviest route, convert it to a deferred import today, measure the initial bundle before and after, then repeat for the next candidate. If you ship a --wasm build, check that you are on Flutter 3.50+ and pass --enable-wasm-deferred-loading before you assume the same behaviour.

Sources