In late 2025, I had three Flutter web apps running in production on the CanvasKit (JavaScript) renderer. A fintech dashboard with real-time charts, a logistics tracking panel, and an inventory management tool. All three worked fine, but the fintech dashboard had a recurring complaint from the client: "the charts feel laggy when there's a lot of data." I'd already optimized the Dart code, added deferred loading, and compressed assets. The bottleneck was the renderer itself.
When Flutter 3.22 made the WASM renderer (called Skwasm) the default for new web builds, I decided to migrate all three. This post documents exactly what I did, what broke, what improved, and the benchmark numbers I measured before and after. If you're considering the switch, this is the data I wish I'd had before I started.
Why I Migrated Three Production Apps
The CanvasKit renderer compiles Dart to JavaScript, then uses a WebGL-based Skia engine to paint pixels.
It works, but there are two bottlenecks: JavaScript execution overhead on complex widget trees, and
single-threaded rendering that blocks the main thread during heavy computation. The WASM renderer
(Skwasm) replaces both: Dart compiles to WebAssembly via dart2wasm, and rendering
runs on a separate thread using SharedArrayBuffer. That means the UI thread stays
responsive even when the renderer is painting a complex frame.
For apps like my fintech dashboard — where a user filters 10,000 transactions and the chart redraws with 200+ data points — the difference between single-threaded and multi-threaded rendering is the difference between "feels broken" and "feels instant." I wrote about general Flutter performance optimization before, but this post is specifically about the web WASM migration.
Migration Summary
Three production apps migrated. Zero Dart code changes required for two of them. One app needed
dart:html imports replaced with package:web. Total migration time:
4 hours for all three, including server configuration changes and testing. Performance improvement
was measurable on every app.
The Three Flutter Web Renderers — A Quick History
Flutter web has gone through three rendering strategies. Understanding the history helps explain why WASM matters:
HTML renderer (deprecated). The original Flutter web renderer. It translated Flutter widgets to standard HTML elements and CSS. Output was lightweight but couldn't match native Flutter rendering fidelity — shadows, clipping, and custom paint operations often looked wrong. This renderer was removed in Flutter 3.22.
CanvasKit renderer. Replaced the HTML renderer as the default. Uses a WebAssembly-compiled
version of Skia (the same graphics engine Flutter uses on mobile) to paint pixels onto a
<canvas> element. Pixel-perfect rendering, but Dart itself is compiled to JavaScript.
The JS execution overhead limits performance on complex widget trees.
Skwasm renderer (WASM). The current default since Flutter 3.22. Compiles Dart to
WebAssembly using dart2wasm. The Skia engine also runs as WASM. Multi-threaded rendering via
SharedArrayBuffer. This is what you get when you run flutter build web --wasm.
# Build with each renderer for comparison
# CanvasKit (JS) — the old default
flutter build web
# Skwasm (WASM) — the new default since Flutter 3.22
flutter build web --wasm
# The --wasm flag tells dart2wasm to compile Dart to
# WebAssembly instead of dart2js compiling to JavaScript.
# The output goes to build/web/ either way.The key difference: with CanvasKit, the browser runs your Dart logic as JavaScript (interpreted or JIT-compiled by V8). With Skwasm, the browser runs your Dart logic as native WebAssembly (ahead-of-time compiled). WebAssembly executes faster because it's a typed, stack-based binary format that the browser can compile to machine code more efficiently than JavaScript. For a deeper look at how Skia and Impeller handle rendering on mobile, check my performance guide.
How dart2wasm Works Under the Hood
The dart2wasm compiler is fundamentally different from dart2js. Understanding the pipeline explains the performance gains:
# dart2js pipeline (CanvasKit)
Dart source → Dart IR → JavaScript → V8 JIT → machine code
↑ runtime cost
# dart2wasm pipeline (Skwasm)
Dart source → Dart IR → Wasm bytecode → browser AOT → machine code
↑ one-time, fastdart2wasm takes advantage of WasmGC (WebAssembly Garbage Collection) — a browser feature that lets WASM modules use the browser's built-in garbage collector instead of managing memory manually. Before WasmGC, Dart compiled to WASM needed a custom GC compiled into the module, making bundles huge. WasmGC eliminates that overhead.
The practical result: dart2wasm produces typed, optimized WebAssembly that the browser can compile to machine code once on first load, then run at near-native speed. JavaScript, by comparison, needs continuous JIT optimization as the browser profiles hot code paths. For compute-heavy Dart code (list operations, JSON parsing, state calculations), the WASM version runs measurably faster.
WasmGC Browser Support
WasmGC landed in Chrome 119, Firefox 120, Edge 119 (November 2023), and Safari 18.2 (released late 2024). That covers ~92% of global browser traffic as of early 2026. For the ~8% on older browsers, Flutter's build output includes a JS fallback that loads automatically. More details in the WebAssembly feature support table.
Building a Flutter Web App with WASM
The build process is a single flag change. No Dart code modifications, no pubspec changes, no
configuration files (despite what some outdated tutorials claim — there's no flutter config
--enable-wasm flag).
# Ensure you're on Flutter 3.22 or later
flutter --version
# Flutter 3.27.3 • channel stable
# Build with WASM
flutter build web --wasm --release
# Build output structure
build/web/
├── flutter.js # Bootstrap script
├── flutter_service_worker.js
├── index.html
├── main.dart.js # JS fallback for older browsers
├── main.dart.mjs # ES module fallback
├── main.dart.wasm # The WASM module (your Dart code)
├── manifest.json
└── assets/
├── fonts/
├── packages/
└── shaders/The build output includes both WASM and JavaScript files. The bootstrap script (flutter.js)
detects browser capabilities at runtime and loads the WASM module if WasmGC is supported, or falls back
to the JS bundle if it's not. You deploy all files — the fallback happens automatically.
// index.html — the bootstrap loads automatically
// No manual renderer selection needed since Flutter 3.22
<!DOCTYPE html>
<html>
<head>
<base href="/">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My App</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter.js" defer></script>
</body>
</html>For local development, I use flutter run -d chrome which runs in debug mode with the JS
renderer (debug mode doesn't support WASM compilation). To test the WASM build locally:
# Build WASM release
flutter build web --wasm --release
# Serve locally with correct headers
# (important: you need COOP/COEP headers for SharedArrayBuffer)
cd build/web
python3 -c "
import http.server, functools
class Handler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
super().end_headers()
extensions_map = {
**http.server.SimpleHTTPRequestHandler.extensions_map,
'.wasm': 'application/wasm',
}
server = http.server.HTTPServer(('localhost', 8080), Handler)
print('Serving at http://localhost:8080')
server.serve_forever()
"Benchmark Results from My Three Apps
I measured the same metrics on each app before (CanvasKit/JS) and after (Skwasm/WASM) migration. All tests ran on the same Chrome 120 instance, same machine (M2 MacBook Pro, 16GB RAM), same network (localhost serving). Each metric is the average of 5 runs with cache cleared between runs.
| Metric | Fintech Dashboard (JS) | Fintech Dashboard (WASM) | Change |
|---|---|---|---|
| Time to first paint | 3.2s | 2.1s | -34% |
| Time to interactive | 4.8s | 3.0s | -38% |
| Chart redraw (200 points) | 180ms | 95ms | -47% |
| List scroll (10K items) | 42fps avg | 58fps avg | +38% |
| Memory (steady state) | 145MB | 128MB | -12% |
| Metric | Logistics Panel (JS) | Logistics Panel (WASM) | Change |
|---|---|---|---|
| Time to first paint | 2.6s | 1.8s | -31% |
| Time to interactive | 3.5s | 2.4s | -31% |
| Map render (500 markers) | 320ms | 210ms | -34% |
| Filter 5K records | 85ms | 45ms | -47% |
| Memory (steady state) | 112MB | 98MB | -13% |
| Metric | Inventory Tool (JS) | Inventory Tool (WASM) | Change |
|---|---|---|---|
| Time to first paint | 1.9s | 1.4s | -26% |
| Time to interactive | 2.8s | 2.0s | -29% |
| Data table sort (2K rows) | 65ms | 38ms | -42% |
| Barcode scan + lookup | 140ms | 90ms | -36% |
| Memory (steady state) | 85MB | 76MB | -11% |
The pattern is consistent: 25-40% faster initial load, 30-47% faster compute-heavy operations, 10-13% less memory usage, and significantly better frame rates during complex rendering. The fintech app saw the largest improvement because it has the most computation (chart rendering, data aggregation) — that maps directly to the type of code where WASM outperforms JavaScript.
How I Measured
Time-to-first-paint and time-to-interactive measured via Chrome DevTools Performance tab (Lighthouse
audit + manual markers). Frame rates measured with the Flutter performance overlay (WidgetsApp
showPerformanceOverlay) and confirmed with Chrome's FPS meter. Memory via Chrome Task Manager.
Each number is the average of 5 runs, cache cleared between runs, extensions disabled, same Wi-Fi
off (localhost serving to eliminate network variance).
Browser Compatibility — The WasmGC Requirement
Flutter WASM requires browsers that support WasmGC. Here's the support matrix I tested against:
| Browser | Minimum Version | Release Date | WASM Status |
|---|---|---|---|
| Chrome | 119 | Nov 2023 | Full support |
| Edge | 119 | Nov 2023 | Full support |
| Firefox | 120 | Nov 2023 | Full support |
| Safari | 18.2 | Dec 2024 | Full support |
| Safari iOS | 18.2 | Dec 2024 | Full support |
| Samsung Internet | 25 | Jan 2024 | Full support |
Safari was the laggard — WasmGC didn't land until version 18.2. For one client, 15% of their users were on Safari 17 or older. The JS fallback handled those users transparently, but the performance disparity was noticeable. I ended up showing a subtle banner suggesting a browser update for Safari users on version 17 or older. Within a month, 80% of those users had updated.
// Detect if the user's browser is running WASM or JS fallback
// Useful for analytics and conditional feature flags
bool get isRunningWasm {
// In WASM mode, int is 64-bit; in JS mode, int is a JS number
// This is the simplest detection method
return identical(0, 0.0) == false;
}
// Use it in your app
void main() {
if (isRunningWasm) {
debugPrint('Running with WASM renderer');
} else {
debugPrint('Running with JS (CanvasKit) fallback');
}
runApp(const MyApp());
}Server Configuration for WASM Deployment
This is where most people get stuck. The WASM renderer needs specific HTTP headers that aren't on by default on most hosting providers. Without them, SharedArrayBuffer is disabled and you lose multi-threaded rendering.
# .htaccess for Apache/LiteSpeed (like Hostinger)
# Add WASM MIME type
AddType application/wasm .wasm
# Enable cross-origin isolation for SharedArrayBuffer
<IfModule mod_headers.c>
Header set Cross-Origin-Opener-Policy "same-origin"
Header set Cross-Origin-Embedder-Policy "require-corp"
</IfModule>
# Enable compression for WASM files
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/wasm
</IfModule>
# Cache WASM files aggressively (content-hashed filenames)
<IfModule mod_expires.c>
ExpiresByType application/wasm "access plus 1 year"
</IfModule># Nginx configuration equivalent
server {
# WASM MIME type (usually already in mime.types)
types {
application/wasm wasm;
}
location / {
# Cross-origin isolation headers
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
# Serve Flutter web app
try_files $uri $uri/ /index.html;
}
# Aggressive caching for static assets
location ~* \.(wasm|js|css|png|jpg|webp|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
}
}I deploy my Flutter web apps on Hostinger's LiteSpeed hosting, which uses .htaccess. The
cross-origin headers are required because SharedArrayBuffer — the API that enables
multi-threaded WASM rendering — is only available in cross-origin isolated contexts. Without these headers, the
Skwasm renderer falls back to single-threaded mode. The app still works, but you lose roughly half the
performance benefit. I covered general Flutter web deployment
patterns in a separate post.
COEP Gotcha — External Resources
Cross-Origin-Embedder-Policy: require-corp blocks loading external resources (images,
fonts, scripts) unless those resources include a Cross-Origin-Resource-Policy header or
you use crossorigin attributes. If you're loading images from a CDN or external API,
add crossorigin="anonymous" to your image tags. Google Fonts already sends the correct
headers, but custom CDNs might not.
Multi-Threaded Rendering with SharedArrayBuffer
The single biggest performance advantage of Skwasm over CanvasKit isn't the WASM compilation — it's multi-threaded rendering. CanvasKit renders on the main thread, meaning every frame painted blocks JavaScript execution (and vice versa). Skwasm offloads rendering to a Web Worker using shared memory.
// This is what happens internally (simplified)
// You don't write this code — Flutter handles it automatically
// Main thread: Dart logic (event handling, state, layout)
// Worker thread: Skia rendering (painting, compositing)
// SharedArrayBuffer provides zero-copy shared memory
// between the main thread and the worker thread.
// The main thread writes layout data, the worker reads
// it and renders — no postMessage serialization overhead.
// To verify multi-threading is active, check the console:
// "Running with CanvasKit renderer" → single-threaded
// "Running with Skwasm renderer" → multi-threaded (if COOP/COEP headers present)In practice, this means your Dart code (event handlers, state management, network calls) runs on the main thread while painting runs on a separate thread simultaneously. On the fintech dashboard, a heavy chart redraw used to block tap events for 180ms. After WASM migration with multi-threaded rendering, the chart still takes ~95ms to redraw, but the UI stays responsive during that time because rendering happens on the worker thread. The user can scroll, tap, and interact while the chart paints.
For apps that run complex animations or heavy custom paint operations, multi-threaded rendering eliminates the "animation jank when data loads" problem that CanvasKit apps commonly have. State management solutions like Riverpod and signals both benefit because state changes trigger rebuilds on the main thread while the previous frame finishes painting on the worker.
Bundle Size Comparison and Optimization
WASM builds are slightly larger than JS builds before compression, but compress better. Here are the actual sizes from my fintech dashboard:
| Build Type | Raw Size | Gzip | Brotli |
|---|---|---|---|
| CanvasKit (JS) | 5.8MB | 1.7MB | 1.4MB |
| Skwasm (WASM) | 7.2MB | 2.1MB | 1.7MB |
| Difference | +1.4MB | +400KB | +300KB |
The raw size difference looks concerning (1.4MB more), but after Brotli compression — which every modern CDN and hosting provider supports — the gap narrows to 300KB. That 300KB extra download is recovered many times over by the faster parse and execution time. WASM modules parse in a single pass (no AST construction like JavaScript), so the browser starts executing code sooner despite the larger download.
# Optimization tips for WASM bundle size
# 1. Tree-shake icons (removes unused Material/Cupertino icons)
flutter build web --wasm --release --tree-shake-icons
# 2. Use deferred imports for routes (covered in next section)
# 3. Enable Brotli compression on your server
# Apache/LiteSpeed .htaccess:
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS application/wasm
AddOutputFilterByType BROTLI_COMPRESS application/javascript
AddOutputFilterByType BROTLI_COMPRESS text/html
AddOutputFilterByType BROTLI_COMPRESS text/css
</IfModule>
# 4. Check what's in the bundle
flutter build web --wasm --release --analyze-size
# Opens a visualization of what's taking up spaceThe Size Tradeoff
On a 4G connection (typical: 3MB/s), the extra 300KB (Brotli-compressed) adds ~100ms to download time. The WASM time-to-interactive improvement averages 1-2 seconds. Net result: the user sees the app faster despite the slightly larger bundle. On slow 3G connections, the tradeoff is tighter — if your users are primarily on slow mobile networks, test both builds and measure.
Migrating Away from dart:html
This was the only code change I needed for one of my three apps. The WASM compiler (dart2wasm) doesn't
support dart:html, dart:js, or dart:js_util. These are
JavaScript-specific APIs that don't exist in a WebAssembly context. The replacements are
package:web and dart:js_interop.
// BEFORE: Using dart:html (breaks with --wasm)
import 'dart:html' as html;
void downloadFile(List<int> bytes, String filename) {
final blob = html.Blob([bytes]);
final url = html.Url.createObjectUrlFromBlob(blob);
html.AnchorElement()
..href = url
..download = filename
..click();
html.Url.revokeObjectUrl(url);
}
// AFTER: Using package:web + dart:js_interop (works with --wasm)
import 'package:web/web.dart' as web;
import 'dart:js_interop';
import 'dart:typed_data';
void downloadFile(Uint8List bytes, String filename) {
final blob = web.Blob(
[bytes.toJS].toJS,
web.BlobPropertyBag(type: 'application/octet-stream'),
);
final url = web.URL.createObjectURL(blob);
final anchor = web.document.createElement('a') as web.HTMLAnchorElement
..href = url
..download = filename;
anchor.click();
web.URL.revokeObjectURL(url);
}// BEFORE: Using dart:js for interop
import 'dart:js' as js;
void callJSFunction() {
js.context.callMethod('myJSFunction', ['hello']);
}
// AFTER: Using dart:js_interop
import 'dart:js_interop';
@JS('myJSFunction')
external void myJSFunction(String message);
void callJSFunction() {
myJSFunction('hello');
}The package:web migration guide covers every dart:html API and its replacement. In my inventory app, I had three files using dart:html for file downloads and clipboard operations. The migration took about 45 minutes. For the other two apps, zero changes were needed because they only used Flutter APIs (no direct browser API calls). If your app uses packages that depend on dart:html internally, check if the package has a WASM-compatible version — most popular packages have already migrated.
Deferred Loading for Faster Initial Paint
Deferred loading (code splitting) works with both JS and WASM builds, but it's more impactful with WASM because the initial module is larger. The idea: only load the code for the current screen, and lazy-load other screens when the user navigates to them.
// lib/features/reports/presentation/reports_screen.dart
// This file is deferred-loaded
import 'package:flutter/material.dart';
class ReportsScreen extends StatelessWidget {
const ReportsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Reports')),
body: const ReportsContent(),
);
}
}
// lib/app/routes.dart — Deferred import
import '../features/reports/presentation/reports_screen.dart'
deferred as reports;
GoRoute(
path: '/reports',
builder: (context, state) => FutureBuilder(
future: reports.loadLibrary(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return reports.ReportsScreen();
}
return const Center(child: CircularProgressIndicator());
},
),
),With deferred loading, the initial WASM module contains only the code for the login screen and shell. Additional modules load as the user navigates. On my fintech dashboard, this reduced the initial paint time from 2.1s to 1.5s — the dashboard screen (with heavy chart libraries) loads as a deferred chunk after the user authenticates. For a deeper dive on structuring features for code splitting, see my clean architecture guide.
Building a PWA with Flutter WASM
Flutter's web build includes service worker support out of the box. For my logistics tracking panel, the client wanted the app to work offline (at least for viewing previously loaded data). This works with WASM the same way it works with JS:
// web/manifest.json — PWA manifest for a Flutter WASM app
{
"name": "Logistics Tracker",
"short_name": "Logistics",
"start_url": ".",
"display": "standalone",
"background_color": "#0D1B2A",
"theme_color": "#0D1B2A",
"description": "Real-time logistics tracking dashboard",
"orientation": "any",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
}
]
}// Custom service worker for caching WASM modules
// web/custom_service_worker.js
const CACHE_NAME = 'flutter-wasm-v1';
const WASM_ASSETS = [
'main.dart.wasm',
'flutter.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(WASM_ASSETS);
})
);
});
self.addEventListener('fetch', (event) => {
// Cache-first for WASM and JS assets
if (event.request.url.endsWith('.wasm') ||
event.request.url.endsWith('.js')) {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
});
})
);
}
});Caching the WASM module in the service worker means returning users get near-instant startup — the 2.1MB WASM file loads from the browser cache instead of the network. On my logistics panel, the second visit loads in 0.8 seconds versus 1.8 seconds on first visit. This is meaningful for apps where users check in multiple times per day. For push notifications to bring users back, I covered the FCM setup separately.
Debugging WASM Apps in Production
Debugging WASM in the browser is different from debugging JavaScript. Stack traces look different, variable names can be mangled, and source maps work differently. Here's my approach:
// Structured logging for WASM production apps
import 'dart:developer' as developer;
import 'package:logging/logging.dart';
final _log = Logger('MyApp');
void setupLogging() {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
developer.log(
record.message,
time: record.time,
level: record.level.value,
name: record.loggerName,
error: record.error,
stackTrace: record.stackTrace,
);
});
}
// In your error handling
void handleError(Object error, StackTrace stack) {
_log.severe('Unhandled error', error, stack);
// Forward to crash reporting service
// Works the same in WASM and JS builds
FirebaseCrashlytics.instance.recordError(
error,
stack,
reason: 'Unhandled exception',
);
}For development, flutter run -d chrome uses the JS renderer with full source maps and
breakpoint support. For testing the WASM build specifically, build with --profile instead
of --release to get better stack traces:
# Profile build — better debugging than release, but slower than debug
flutter build web --wasm --profile
# Then serve and inspect with Chrome DevTools
# The Performance tab and Memory tab work normally with WASM builds
# The Sources tab shows WASM module internals
# For production error monitoring, I use Sentry
# Add to pubspec.yaml:
# sentry_flutter: ^8.10.0
// Initialize in main.dart
await SentryFlutter.init(
(options) {
options.dsn = const String.fromEnvironment('SENTRY_DSN');
},
appRunner: () => runApp(const ProviderScope(child: MyApp())),
);WASM Debugging Tip
If you get cryptic error messages in the WASM build that don't appear in the JS build, 90% of the
time it's a dart:html import somewhere in your dependency tree. Run
flutter build web --wasm and read the compiler errors carefully — dart2wasm lists
every incompatible import. The fix is usually updating the package to a newer version that uses
package:web instead.
When WASM Is the Wrong Choice
WASM isn't always better. Here's when I stick with the JS renderer:
SEO-critical public pages. Flutter web apps — WASM or JS — render client-side. Search engines can render JavaScript to some degree, but WASM-rendered content is essentially invisible to crawlers. If search indexing matters, don't use Flutter web at all. Use a JavaScript framework with server-side rendering or static HTML. I wrote about this trade-off in my Flutter for startups post.
Users on very slow connections. The WASM bundle is 300KB larger (compressed) than JS. On 2G connections, that's an extra 2-3 seconds of download time. The performance gains from WASM don't offset the download cost if users will leave before the app loads. For apps targeting users in areas with poor connectivity, the JS build loads faster initially.
Simple content apps. If your Flutter web app is mostly text, images, and navigation with minimal computation, the JS renderer is fine. The WASM performance advantage is proportional to how much computation your app does on the client side. A blog reader app won't benefit much. A data analysis dashboard benefits enormously.
Environments blocking SharedArrayBuffer. Some corporate networks and security proxies strip cross-origin headers. Without COOP/COEP headers, SharedArrayBuffer is disabled and Skwasm falls back to single-threaded mode. If your users are behind restrictive corporate proxies, the multi-threaded advantage vanishes. For security-sensitive deployments, test behind the same proxies your users will use.
Decision Guide
Use WASM when: your app has complex UI (charts, maps, data tables, animations), your users are on
modern browsers, and your server supports custom headers. Use JS when: your users are on slow networks,
older browsers account for more than 20% of traffic, or your app is primarily text and images.
When in doubt, build with --wasm — the automatic JS fallback means users on older
browsers still get a working app.
My Full Deployment Pipeline
Here's the complete pipeline I use for deploying Flutter WASM web apps to production. I've been using this for six months across all three apps:
#!/bin/bash
# deploy-flutter-web.sh — My production deployment script
set -e
APP_NAME=$1
REMOTE_PATH="/home/user/domains/$APP_NAME/public_html"
echo "=== Building $APP_NAME with WASM ==="
flutter clean
flutter pub get
flutter build web --wasm --release --tree-shake-icons \
--dart-define-from-file=env/production.json
echo "=== Verifying WASM output ==="
if [ ! -f "build/web/main.dart.wasm" ]; then
echo "ERROR: WASM file not found"
exit 1
fi
# Check WASM file size (warn if over 5MB compressed)
WASM_SIZE=$(wc -c < build/web/main.dart.wasm)
echo "WASM module size: $(( WASM_SIZE / 1024 / 1024 ))MB"
echo "=== Deploying to server ==="
rsync -avz --delete \
--exclude='.git' \
--exclude='*.map' \
build/web/ "$SSH_USER@$SSH_HOST:$REMOTE_PATH/"
echo "=== Verifying deployment ==="
HTTP_STATUS=$(curl -sI "https://$APP_NAME" | head -1 | awk '{print $2}')
if [ "$HTTP_STATUS" -eq 200 ]; then
echo "Deployment successful — $APP_NAME is live"
else
echo "WARNING: HTTP $HTTP_STATUS — check deployment"
fiThe --dart-define-from-file flag injects environment variables (API URLs, analytics keys)
at build time without hardcoding them in source code. A separate env/production.json file
holds the values and never gets committed to git. For secrets
management best practices, see my security guide. For payment integration where API keys are especially
sensitive, the same pattern applies.
What Changed in Flutter 3.22+ for Web
Flutter 3.22 (released mid-2025) was the turning point for Flutter web. The changes relevant to WASM:
Skwasm became the default web renderer. Before 3.22, you needed --wasm as
an opt-in flag. Now flutter build web uses Skwasm by default if the browser supports it.
The flag still works for explicit control.
HTML renderer was removed. The old HTML renderer (DOM-based) was deprecated in 3.19 and removed in 3.22. All Flutter web apps now use either Skwasm (WASM) or CanvasKit (JS). This simplified the rendering pipeline and let the team focus optimization efforts.
Improved tree shaking for WASM. dart2wasm's tree shaker got better at eliminating dead code, reducing bundle sizes by 10-15% compared to Flutter 3.19 WASM builds. The app size tool in DevTools visualizes exactly what's included in your WASM bundle.
Better renderer fallback logic. The bootstrap script now detects WasmGC support more reliably and falls back to JS faster if WASM isn't supported (previously there was a noticeable delay during detection on some Safari versions).
Improved text rendering in Skwasm. Text rendering with custom fonts was a known issue in early Skwasm builds — ligatures and complex scripts (Arabic, Devanagari) sometimes rendered incorrectly. Flutter 3.22+ fixed these issues. I verified this on my banking app which uses both English and Urdu text.
The combination of WASM being the default, removed HTML renderer cruft, and improved tree shaking makes
Flutter 3.22+ the first version where I can confidently recommend Flutter web for production apps
without caveats about renderer selection. The old "pick a renderer" complexity is gone — build with
flutter build web and you get the best renderer for each user's browser automatically. If
you're on an older Flutter version, upgrading
to the latest is the single highest-impact change you can make for web performance.
Quick Migration Checklist
1. Upgrade to Flutter 3.22+ (flutter upgrade). 2. Search your code for
dart:html / dart:js imports — migrate to package:web and
dart:js_interop. 3. Run flutter build web --wasm and fix any compiler
errors. 4. Add COOP/COEP headers to your server. 5. Test on Chrome, Firefox, Safari, and Edge. 6.
Deploy both WASM and JS files — the fallback happens automatically. 7. Monitor performance with the
same tools you're already using.
Related Resources
For a step-by-step walkthrough of testing WASM builds alongside your existing test suite, see my Flutter testing strategy guide. Integration tests run identically on both JS and WASM builds — your existing test suite works without modifications.
I've been running all three apps on the WASM renderer for over three months now. The fintech client stopped complaining about chart lag. The logistics panel loads faster on the warehouse tablets. The inventory tool sorts 2,000 rows in 38ms instead of 65ms. The migration was one of the highest return-on-effort optimizations I've done — four hours of work for a permanent 25-40% performance improvement across three production apps. If you have Flutter web apps still running on the JS renderer, the switch is worth your time.
Frequently Asked Questions
How much faster is Flutter WASM compared to the JavaScript renderer?
From my benchmarks across three production apps, the WASM renderer (Skwasm) averages 25-40% faster initial paint times and 15-30% better frame rates for animation-heavy screens compared to the old CanvasKit JavaScript renderer. The biggest gains are in compute-heavy operations like filtering large datasets, rendering complex charts, and initial widget tree construction. Simple text-heavy pages show less improvement because the bottleneck there is network and font loading, not rendering.
Does Flutter WASM work in all browsers?
Flutter WASM requires WasmGC support in the browser. As of early 2026, Chrome 119+, Firefox 120+, Edge 119+, and Safari 18.2+ all support WasmGC. That covers roughly 92% of global browser traffic. For users on older browsers, Flutter automatically falls back to the JavaScript CanvasKit renderer, so your app still works — just without the WASM performance gains.
Is the WASM build larger or smaller than the JavaScript build?
The initial WASM module is typically 1-3MB larger than the equivalent JavaScript bundle before compression. After Brotli compression, the difference narrows to 200-500KB. The tradeoff is worth it because WASM parses and compiles faster than JavaScript in the browser, so the slightly larger download leads to faster execution. For a dashboard app I deployed, the compressed WASM bundle was 2.1MB versus 1.7MB for JS — but time-to-interactive was 1.8 seconds faster with WASM.
Can I use Flutter WASM for public-facing marketing websites?
I wouldn't recommend it. Flutter web — whether WASM or JS — generates a single-page app that search engines struggle to index. The initial bundle is multi-megabyte, and the shell renders blank until Flutter boots. For marketing sites, landing pages, and content-heavy sites where SEO matters, stick with HTML/CSS/JS or a JavaScript framework with SSR. Flutter WASM works best for authenticated dashboards, admin panels, internal tools, and data-heavy apps where the user lands on a login screen first.
Do I need to change my Dart code when migrating from CanvasKit to WASM?
For most apps, no Dart code changes are needed — the migration is a build flag change:
flutter build web --wasm instead of flutter build web. However, if
your app uses dart:html or dart:js directly, those APIs aren't
available under the WASM renderer. You need to migrate to package:web and
dart:js_interop instead. The compiler flags every incompatible import during the
build, so nothing breaks silently.
What server configuration does Flutter WASM need?
Your server must serve .wasm files with the correct MIME type
(application/wasm) and include cross-origin headers:
Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp. These are required for
SharedArrayBuffer, which enables multi-threaded rendering. Without these headers,
the renderer falls back to single-threaded mode — the app works but loses about half the
performance benefit. Add these to your .htaccess (Apache/LiteSpeed) or server
block (Nginx).
Should I migrate my existing Flutter web app from CanvasKit to WASM?
If your app targets modern browsers and performance matters, yes. The migration is low-risk —
a build flag change with automatic JS fallback for unsupported browsers. I migrated three
production apps and every one showed measurable improvement. The only reason to hold off is if
your app relies on dart:html APIs that haven't been migrated to
package:web yet. Check your imports, switch the build flag, and test.
How do I debug Flutter WASM apps in the browser?
Chrome DevTools works for WASM debugging, but variable names may be mangled in release builds.
For development, use flutter run -d chrome which runs in debug mode with source
maps. Dart DevTools (Widget Inspector, Performance overlay, Network profiler)
all work normally with WASM builds. For production, add structured logging with
package:logging and forward errors to Sentry or Firebase Crashlytics. Build with
--profile instead of --release if you need better stack traces during
testing.