Flutter Web

I Migrated 3 Flutter Web Apps to WASM — Here Are the Real Performance Numbers

Muhammad Shakil Muhammad Shakil
Mar 04, 2026
24 min read
Flutter WASM web performance — migrating production apps from CanvasKit to WebAssembly
Back to Blog

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, fast

dart2wasm 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 space

The 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"
fi

The --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.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.