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
.wasmand.mjsis worth 60–70% off the wire. Confirmcontent-encoding: brin 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-iconsis on by default in release builds; verify it in the build log, and subset any custom fonts. - Defer heavy features.
deferred asstill 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:
- Test both. In CI, run your integration suite against the Wasm build and the JS build. A
dart:js_interopshim that works underdart2wasmcan behave differently underdart2jsaround numeric types (Dartintis a JS double there) andJSAnyconversions. - 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_utilimports, 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 === truein production - Cross-origin subresources send CORP or are self-hosted
-
application/wasmMIME type and Brotli compression confirmed on the CDN - Static loading state in
index.html, removed onflutter-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.