+1 (415) 480-3939

Shipping Flutter Web on WebAssembly: WasmGC, Hosting Headers, and Load-Time Budgets

Flutter web spent years being the part of the framework nobody wanted to demo. It worked, but the honest pitch was "it's fine for an internal tool". The WebAssembly build target changed the arithmetic: dart2wasm compiles your Dart to WasmGC instead of JavaScript, and on a mid-range laptop the frame-time difference on scroll- and animation-heavy screens is not subtle.

This tutorial is the build-and-ship path we walk clients through when they ask whether the web build is worth turning on: what the compiler actually changes, the code you have to fix before it compiles, the hosting headers that trip up every first deploy, and the load-time budget that decides whether users ever see your app.

What --wasm actually changes

There are two independent axes on Flutter web, and people conflate them constantly.

Compiler. dart2js emits JavaScript. dart2wasm emits a WebAssembly module using WasmGC — the garbage-collected Wasm proposal, now shipping in Chromium and Firefox, with Safari support arriving later than the others. WasmGC matters because Dart's object model needs a GC; without it you would have to ship your own collector in the bundle, which is exactly why earlier Wasm attempts were unattractive.

Renderer. CanvasKit paints through a Skia build compiled to Wasm. skwasm is the newer renderer that pairs with the Wasm compiler and moves rasterization to a worker. The old html renderer is gone; if your build scripts still pass --web-renderer html, that flag no longer exists.

So flutter build web --wasm means: Dart compiled to WasmGC, painting via skwasm, with a JavaScript build emitted alongside as the fallback for browsers that lack WasmGC. You ship both. The bootstrap script picks at runtime.

# JS build (the old default)
flutter build web --release

# WasmGC build + JS fallback in one output directory
flutter build web --wasm --release

Check what you actually got:

ls -lh build/web
# main.dart.wasm        <- WasmGC module
# main.dart.mjs         <- Wasm bootstrap
# main.dart.js          <- JS fallback
# flutter_bootstrap.js  <- decides which one loads
# canvaskit/            <- fallback renderer assets

Step 1: make the code platform-safe

Most real apps do not compile to Wasm on the first try, and the failures cluster in three places.

dart:html is not available

dart:html, dart:js, and dart:js_util are unsupported under dart2wasm. Replace them with package:web and dart:js_interop.

// Before
import 'dart:html' as html;

void setTitle(String t) => html.document.title = t;

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

void setTitle(String t) => web.document.title = t;

The rewrite is usually mechanical, but the types are stricter: package:web uses extension types over JS objects, so implicit dynamic calls you got away with under dart:js_util now need explicit interop declarations.

Conditional imports for shared code

If a file has to run on both mobile and web, do not import a web library at the top level. Use conditional imports so the mobile build never sees it.

// lib/platform/clipboard.dart
export 'clipboard_io.dart'
    if (dart.library.js_interop) 'clipboard_web.dart';
// lib/platform/clipboard_web.dart
import 'dart:js_interop';
import 'package:web/web.dart' as web;

Future<void> copy(String text) async {
  await web.window.navigator.clipboard.writeText(text).toDart;
}
// lib/platform/clipboard_io.dart
import 'package:flutter/services.dart';

Future<void> copy(String text) =>
    Clipboard.setData(ClipboardData(text: text));

Note the condition key: use dart.library.js_interop, not dart.library.html. The html key is false under dart2wasm, which silently sends you down the io branch and produces a build that compiles and then fails at runtime.

Plugins and transitive dependencies

A single dependency that still imports dart:html will fail the whole build. Find them before you start refactoring:

dart pub deps --style=compact
grep -rn "dart:html\|dart:js_util" $(dart pub cache list --format=json >/dev/null 2>&1; echo .) --include="*.dart" | head

In practice the offenders are older analytics SDK wrappers, file-picker forks, and anything that hasn't published since the package:web migration. Options, in order of preference: upgrade the package, replace it with a thin interop shim of your own, or gate the feature off on web behind a capability check.

Declaring your own JS interop

When you need to call a script you loaded in index.html:

import 'dart:js_interop';

@JS('analytics.track')
external void _track(JSString event, JSAny payload);

void track(String event, Map<String, Object?> payload) {
  _track(event.toJS, payload.jsify()!);
}

Keep every external declaration in one interop/ folder. When a vendor changes their global API, you want one file to fix, not fifteen call sites.

Step 2: hosting headers, the step everyone skips

The Wasm build wants to rasterize off the main thread, and multi-threaded Wasm needs SharedArrayBuffer, which browsers only expose to cross-origin isolated documents. That means two response headers on the document:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Without them the app still loads — it just quietly drops to a slower single-threaded path, and your benchmark shows no improvement over the JS build. This is the single most common reason a team concludes "Wasm did nothing for us".

The catch: require-corp breaks any cross-origin subresource that doesn't opt in. Third-party fonts, map tiles, analytics pixels, and embedded iframes all need Cross-Origin-Resource-Policy: cross-origin (or CORS plus crossorigin attributes) or they will be blocked. credentialless is a gentler alternative on Chromium but is not universally supported, so test rather than assume.

nginx:

location / {
  add_header Cross-Origin-Opener-Policy   same-origin   always;
  add_header Cross-Origin-Embedder-Policy require-corp   always;

  # correct MIME type or the module won't stream-compile
  types { application/wasm wasm; }

  # hashed assets: cache hard
  location ~* \.(wasm|mjs|js|json|otf|ttf)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
  }

  # never cache the entry point
  location = /index.html {
    add_header Cache-Control "no-cache";
  }
}

Firebase Hosting:

{
  "hosting": {
    "public": "build/web",
    "headers": [
      {
        "source": "/**",
        "headers": [
          { "key": "Cross-Origin-Opener-Policy",   "value": "same-origin" },
          { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
        ]
      },
      {
        "source": "/index.html",
        "headers": [{ "key": "Cache-Control", "value": "no-cache" }]
      }
    ]
  }
}

Verify in production, not locally:

curl -sI https://app.example.com/ | grep -i cross-origin

And in the browser console, crossOriginIsolated must be true.

Step 3: control the load

A Flutter web app is a single large download before first paint. That is the real product risk, not frame time. Take control of the bootstrap instead of shipping the generated default.

<!-- web/index.html -->
<body>
  <div id="loading">
    <!-- static markup, styled inline: this is your real first paint -->
    <div class="spinner"></div>
  </div>
  <script src="flutter_bootstrap.js" async></script>
  <script>
    window.addEventListener('flutter-first-frame', function () {
      document.getElementById('loading').remove();
    });
  </script>
</body>

Things that measurably help:

  • Preload the module. <link rel="preload" href="main.dart.wasm" as="fetch" crossorigin> so the download starts before the bootstrap script parses.
  • Compress properly. Brotli on .wasm and .mjs is worth 60–70% off the wire. Confirm content-encoding: br in response headers; a surprising number of CDNs skip Wasm by default because of the MIME type.
  • Trim fonts. Icon fonts and multi-weight variable fonts are frequently a bigger download than your app logic. --tree-shake-icons is on by default in release builds; verify it in the build log, and subset any custom fonts.
  • Defer heavy features. deferred as still works on web and is the only real answer to a large app: keep admin screens, chart libraries, and PDF viewers out of the initial payload.
import 'reports/reports_screen.dart' deferred as reports;

Future<Widget> openReports() async {
  await reports.loadLibrary();
  return reports.ReportsScreen();
}

Measure with a cold cache on a throttled connection, and record the numbers per release:

# transfer sizes of the critical path
ls -l build/web/main.dart.wasm build/web/main.dart.mjs
brotli -q 11 -c build/web/main.dart.wasm | wc -c

Our working budget on client projects: under 3 MB compressed on the critical path, first frame under 2.5 s on a throttled 4G profile. If a release blows the budget, something gets deferred before it ships.

Step 4: keep the JS fallback honest

Because WasmGC support is not universal, the JS build is not a formality — for some of your users it is the app. Two rules:

  1. Test both. In CI, run your integration suite against the Wasm build and the JS build. A dart:js_interop shim that works under dart2wasm can behave differently under dart2js around numeric types (Dart int is a JS double there) and JSAny conversions.
  2. Know which one users got. Report the runtime target with your analytics or error reports, so a crash cluster can be traced to a compiler rather than guessed at.
bool get isWasm => const bool.fromEnvironment('dart.tool.dart2wasm');

Add that to your error metadata and the next "only happens for some users" bug takes an hour instead of a week.

Step 5: CI

# .github/workflows/web.yml
name: web
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true
      - run: flutter pub get
      - run: flutter analyze
      - run: flutter build web --wasm --release --base-href /
      - name: enforce payload budget
        run: |
          size=$(brotli -q 11 -c build/web/main.dart.wasm | wc -c)
          echo "wasm brotli bytes: $size"
          test "$size" -lt 3145728
      - uses: actions/upload-artifact@v4
        with:
          name: web-build
          path: build/web

A failing size check is far cheaper than discovering the regression from a bounce-rate chart three weeks later.

When we recommend the web build — and when we don't

Good fits: internal tools and dashboards that already exist as a Flutter mobile app; canvas-heavy editors and visualization UIs where per-frame cost dominates; demo and trial surfaces where "click a link" beats "install an app"; and Flutter-based desktop-class apps that need a browser entry point.

Poor fits: content and marketing pages that live on SEO — a Wasm bundle is the wrong tool for a page that should be server-rendered HTML; anything where a hard cross-origin isolation requirement conflicts with embedded third-party widgets you don't control; and apps whose audience skews to browsers without WasmGC, where you'd be maintaining two targets for the benefit of the slower one.

The honest summary: the Wasm target has moved Flutter web from "acceptable for internal tools" to "a legitimate delivery channel for app-like products". It has not made it a website framework, and it never will be.

Checklist

  • No dart:html / dart:js / dart:js_util imports, in your code or your dependencies
  • Conditional imports keyed on dart.library.js_interop
  • All JS interop declarations in one folder
  • COOP/COEP headers set; crossOriginIsolated === true in production
  • Cross-origin subresources send CORP or are self-hosted
  • application/wasm MIME type and Brotli compression confirmed on the CDN
  • Static loading state in index.html, removed on flutter-first-frame
  • Heavy routes behind deferred as
  • Payload budget enforced in CI
  • Integration tests green on both the Wasm and JS builds
  • Runtime target attached to error reports

If you are weighing whether to add a web target to an existing Flutter app — or you have a Wasm build that loads slower than the JS one did — get in touch. Our Flutter consultants do this work on client codebases, and the first conversation is usually enough to tell you whether the payoff is there.