Skip to main content

One post tagged with "COOP/COEP"

View All Tags

Tuning Flutter WASM 3.0: Web Performance, Garbage Collection, and Production Readiness

Published: · 13 min read
Sandra Rosa Antony
Software Engineer, Appxiom

Flutter on the web is entering a new phase with first-class WebAssembly (WASM) support via Dart’s WasmGC backend. For many apps, switching from JavaScript builds to Flutter WebAssembly WASM delivers faster startup, smoother frames, and more predictable memory behavior. But hitting production-grade performance requires more than just a build flag.

This guide explains what actually changes with WASM on Flutter web, how to tune performance, how WASM garbage collection affects your app, and a pragmatic migration path to production. We’ll cover renderer choices, caching, deferred loading, GC-aware patterns, headers for cross-origin isolation, and troubleshooting.

Applies to:

  • Flutter 3.22+ (recommended current stable)
  • Dart 3.4+ with WasmGC
  • Chrome/Edge 119+, Firefox 120+, Safari 17.4+ (varying degrees of WasmGC support; verify on your target browsers)

Primary focus: Flutter WebAssembly WASM
Secondary: Flutter Web performance, WASM garbage collection, Flutter WASM migration, Flutter web optimization

What changes with Flutter WebAssembly (WasmGC)?

  • Code generation: Instead of Dart2JS, your Dart code compiles to a WasmGC module that the browser can instantiate natively. This significantly reduces JS interop overhead and improves CPU-bound performance and startup on supported browsers.
  • Rendering stays the same: You still pick a web renderer (html or canvaskit). WASM changes how Dart executes - not which drawing API is used.
  • Garbage collection: With WasmGC, Dart objects live on the WebAssembly heap managed by the browser’s GC. This reduces JS object mirroring and can improve memory usage and GC pause behavior.
  • Browser compatibility: WASM GC is broadly available in modern Chromium and Firefox. Safari support is newer; always test the exact versions your users run (especially on iOS).

When to choose WASM vs JS:

  • Choose WASM for modern browser audiences, CPU-heavy code, or startup-time sensitive apps. Keep a JS fallback (or a browser gate) for legacy/older Safari until your analytics prove safe coverage.

Build and run: commands, renderers, and fallback

Build with WASM

# Debug/serve
flutter run -d chrome --wasm --web-renderer canvaskit

# Profile build for performance testing
flutter build web --profile --wasm --web-renderer canvaskit

# Production build
flutter build web --release --wasm --web-renderer canvaskit \
--tree-shake-icons

Notes:

  • Renderer choice: --web-renderer canvaskit for pixel-perfect rendering with Skia (WebGL/WASM), or --web-renderer html for lighter text-heavy UIs. Test both.
  • Service worker: Flutter ships a PWA service worker by default for web. You can tweak via --pwa-strategy=offline-first|none.

Feature detection and graceful fallback

For maximum reach, serve a JS build on browsers without WasmGC. Keep two builds and route in HTML:

<script>
async function supportsWasmGC() {
try {
// WasmGC feature detection (approximate)
const hasWasm = typeof WebAssembly === 'object';
// Many browsers with WasmGC also expose GC-related features internally,
// but the safest production gate is UA+known versions you’ve tested.
return hasWasm;
} catch (_) { return false; }
}

(async () => {
const useWasm = await supportsWasmGC();
const base = useWasm ? '/builds/wasm/' : '/builds/js/';
const script = document.createElement('script');
script.src = base + 'flutter_bootstrap.js';
document.head.appendChild(script);
})();
</script>

Keep both builds published under different prefixes. Measure adoption over time and eventually retire the JS build when safe.

Server and CDN configuration for WASM

Correct headers and compression are crucial to WASM startup time and safety:

  • MIME type: application/wasm for .wasm
  • Compression: Prefer Brotli (.br) for .wasm, .js, and .css
  • Caching: Long Cache-Control with hashed filenames from Flutter’s build
  • Cross-origin isolation (for future WASM threads, OffscreenCanvas, and better perf): COOP/COEP

Example NGINX:

types {
application/wasm wasm;
}

# Let Flutter’s hashed assets cache aggressively
location ~* \.(wasm|js|css|png|jpg|svg|json|ttf|otf)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}

# Brotli (ensure module installed and enabled)
brotli on;
brotli_comp_level 6;
brotli_types application/wasm application/javascript text/css application/json;

# Cross-origin isolation (only if you understand implications)
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;

# If you enable COEP, make sure all subresources are CORP-compliant or CORS-enabled.

Measure TTFB and total transfer size with and without Brotli. WASM often benefits substantially from Brotli.

Performance tuning for Flutter WebAssembly WASM

1. Startup time

  • Preload critical assets in web/index.html:
    <link rel="preload" as="fetch" href="flutter.js" crossorigin>
    <link rel="preload" as="fetch" href="assets/FontManifest.json" crossorigin>
    <link rel="preload" as="font" href="assets/fonts/MaterialIcons-Regular.otf" type="font/otf" crossorigin>
  • Cull code size with deferred imports (code splitting). Load heavy features on demand.
  • Minimize synchronous work in main(). Defer non-critical setup to first frame idle via WidgetsBinding.instance.addPostFrameCallback.

2. Code splitting with deferred imports

// lib/features/charts/charts.dart (heavy feature)
import 'package:flutter/widgets.dart';

class ChartsScreen extends StatelessWidget {
const ChartsScreen({super.key});
@override
Widget build(BuildContext context) => const Text('Charts');
}

// lib/main.dart
import 'package:flutter/material.dart';
import 'features/charts/charts.dart' deferred as charts;

void main() => runApp(const App());

class App extends StatelessWidget {
const App({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Builder(
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('WASM Split Demo')),
body: Center(
child: ElevatedButton(
onPressed: () async {
await charts.loadLibrary(); // fetch deferred module
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const charts.ChartsScreen(),
));
},
child: const Text('Open Charts'),
),
),
),
),
);
}
}

Deferred loading reduces the initial WASM module size and speeds up first paint.

3. Runtime frame smoothness

  • Minimize per-frame allocations. Prefer const widgets, reuse painters, memoize expensive layouts.
  • Batch setState/provider updates to avoid rebuild storms.
  • For lists, prefer ListView.builder, AutomaticKeepAliveClientMixin where appropriate, and image placeholders to avoid jank.
  • Use Image.network with cacheWidth/cacheHeight to request downscaled images from your CDN.

4. Network and JSON

  • Avoid massive intermediate allocations. Use streaming JSON decoding when practical.
import 'dart:convert' as convert;
import 'dart:typed_data';

Stream<Map<String, dynamic>> streamJsonArray(Stream<List<int>> source) async* {
final decoder = convert.Utf8Decoder();
final chunked = convert.JsonDecoder().startChunkedConversion(
convert.ChunkedConversionSink.withCallback((value) {}),
);
// For brevity, consider using packages that support json streaming arrays.
// The key: process chunks, not whole payloads.
}
  • Prefer package:dio with cancellation and timeouts. Limit concurrency.

WASM garbage collection: what to change in your code

With Flutter WebAssembly WASM, Dart objects reside on the Wasm heap, managed by the browser’s GC (WasmGC). This reduces the JavaScript-to-Dart bridging overhead common in Dart2JS builds. Practical implications:

  • Short-lived object churn still impacts GC. Reduce throwaway allocations in tight loops.
  • Finalizers work on the web. Use Finalizer to release underlying JS/Web APIs or engine resources if you wrap them.
  • Interop: Prefer package:web (typed DOM bindings) and dart:js_interop for direct JS APIs. Avoid accidental persistent global references from JS to Dart objects.

Using Finalizer to release JS-backed resources

If you wrap a JS resource that requires explicit release (e.g., a WebGL handle in your custom integration), ensure cleanup even if the Dart object isn’t manually disposed:

import 'dart:js_interop';

// Hypothetical JS bindings (provide JS impl in index.html or a module)
@JS('disposeHandle')
external void jsDisposeHandle(JSAny handle);

// Wrap a JS object with a finalizer to ensure cleanup.
class JsHandleWrapper {
final JSAny _handle;
static final _finalizer = Finalizer<JSAny>((h) {
jsDisposeHandle(h);
});

JsHandleWrapper(this._handle) {
_finalizer.attach(this, _handle, detach: this);
}

void dispose() {
// Manual explicit cleanup is still best practice
_finalizer.detach(this);
jsDisposeHandle(_handle);
}
}

Pattern:

  • Dispose eagerly in your dispose() method.
  • Use a Finalizer as a safety net in case a consumer forgets.

A production-ready data feature: networking, state, and memory discipline

This example shows an infinite scroll feed with Riverpod + Dio designed to minimize rebuilds, cancel stale requests, and avoid leaks.

pubspec.yaml (relevant parts):

dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
dio: ^5.5.0+1

Providers and pagination logic:

// lib/core/networking/dio_client.dart
import 'package:dio/dio.dart';

Dio createDio() {
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 7),
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 15),
headers: {
'Accept': 'application/json',
},
));
return dio;
}
// lib/features/feed/data/feed_repository.dart
import 'package:dio/dio.dart';

class FeedItem {
final String id;
final String title;
const FeedItem({required this.id, required this.title});
}

class FeedRepository {
FeedRepository(this._dio);
final Dio _dio;

Future<List<FeedItem>> fetchPage(int page, CancelToken cancel) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/feed',
queryParameters: {'page': page},
cancelToken: cancel,
);
final items = (res.data?['items'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>()
.map((m) => FeedItem(id: m['id'] as String, title: m['title'] as String))
.toList(growable: false);
return items;
}
}
// lib/features/feed/state/feed_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../../../core/networking/dio_client.dart';
import '../data/feed_repository.dart';

final dioProvider = Provider<Dio>((ref) => createDio());

final feedRepositoryProvider = Provider<FeedRepository>((ref) {
return FeedRepository(ref.watch(dioProvider));
});

class FeedState {
final List<FeedItem> items;
final bool isLoading;
final bool endReached;
const FeedState({this.items = const [], this.isLoading = false, this.endReached = false});

FeedState copyWith({List<FeedItem>? items, bool? isLoading, bool? endReached}) {
return FeedState(
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
endReached: endReached ?? this.endReached,
);
}
}

class FeedNotifier extends AutoDisposeNotifier<FeedState> {
CancelToken? _inflight;

@override
FeedState build() {
ref.onDispose(() {
_inflight?.cancel('disposed');
_inflight = null;
});
return const FeedState();
}

Future<void> loadNextPage() async {
if (state.isLoading || state.endReached) return;
state = state.copyWith(isLoading: true);
_inflight?.cancel('new request');
final cancel = CancelToken();
_inflight = cancel;

try {
final repo = ref.read(feedRepositoryProvider);
final nextPage = (state.items.length ~/ 20) + 1;
final pageItems = await repo.fetchPage(nextPage, cancel);
if (cancel.isCancelled) return;
if (pageItems.isEmpty) {
state = state.copyWith(isLoading: false, endReached: true);
} else {
state = state.copyWith(
items: [...state.items, ...pageItems],
isLoading: false,
);
}
} on DioException catch (_) {
if (cancel.isCancelled) return;
state = state.copyWith(isLoading: false);
}
}
}

final feedProvider = AutoDisposeNotifierProvider<FeedNotifier, FeedState>(FeedNotifier.new);
// lib/features/feed/ui/feed_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/feed_providers.dart';

class FeedScreen extends ConsumerWidget {
const FeedScreen({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final feed = ref.watch(feedProvider);
return NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.pixels / (n.metrics.maxScrollExtent + 1) > 0.8) {
ref.read(feedProvider.notifier).loadNextPage();
}
return false;
},
child: ListView.builder(
itemCount: feed.items.length + (feed.endReached ? 0 : 1),
itemBuilder: (context, i) {
if (i >= feed.items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final item = feed.items[i];
// Keep rows allocation-light
return ListTile(
title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis),
);
},
),
);
}
}

Why this is WASM/GC-friendly:

  • Limits allocations per frame and avoids rebuild storms.
  • Cancels in-flight network calls to prevent orphaned futures and leaks.
  • Uses AutoDispose so providers and CancelTokens are cleaned up promptly.

Renderer selection: HTML vs CanvasKit under WASM

  • HTML renderer: Smaller payload, better for text-heavy apps or simple UIs. Limited advanced effects.
  • CanvasKit: Closer to mobile fidelity, reliable text/shape rendering, consistent custom painting. Larger payload and GPU dependency.

Under Flutter WebAssembly WASM, your Dart runs faster regardless of renderer, but renderer choice still dictates rendering performance and binary size. Always A/B test both on your target devices.

Production readiness checklist for Flutter WASM migration

  1. Upgrade and validate
  • Use Flutter 3.22+ and Dart 3.4+ (or newer stable).
  • Ensure your CI environment uses the same channel.
  1. Package audit
  • Replace dart:io usage with web-friendly alternatives (http, universal_html, package:web).
  • Validate packages that use isolates, file I/O, or platform channels for web shims.
  • Remove or gate code paths that rely on VM-only APIs.
  1. Two-build strategy (initially)
  • Produce both --wasm and JS builds. Route by UA/version or capability detection.
  • Track adoption via RUM metrics to decide when to drop the JS build.
  1. Headers and hosting
  • Serve .wasm with application/wasm, Brotli, long cache with immutable filenames.
  • Consider COOP/COEP if you plan for WASM threads and OffscreenCanvas later.
  • Preload critical assets in index.html.
  1. Deferred loading and images
  • Split heavy routes/features with deferred as.
  • Optimize images via CDN with width/quality parameters. Use cacheWidth/cacheHeight.
  1. Monitoring and profiling
  • Implement RUM (e.g., Appxiom or a lightweight beacon) to track FCP/LCP/INP and memory.
  • Use Chrome DevTools Performance/Mem tabs on real devices.
  • Use Flutter DevTools (profile builds) to inspect frames and rebuilds.

Debugging, profiling, and testing under WASM

  • Source-level debugging: Browser developer tools now have improving support for WASM DWARF/source maps. Expect better but not identical-to-VM debugging.
  • Performance profiling: Use Chrome DevTools Performance to record startup and interactions. Watch layout/paint timings and long tasks.
  • Memory profiling: Use Allocation sampling to identify hot allocation paths even in WASM builds.
  • Flutter DevTools: Use for widget rebuild profiling, CPU sampling (profile mode), and network tracking.
  • Integration tests: integration_test with a headless Chrome runner in CI covers core user flows.
  • Real-User Monitoring (RUM): Pair DevTools profiling with an APM tool like Appxiom to continuously track client-side performance, frame stability, and memory overhead across different user

Common pitfalls and troubleshooting

  • Error: “WebAssembly.instantiate(): Feature ‘gc’ not available”

    • Cause: Browser lacks WasmGC or enterprise policy disables it.
    • Fix: Serve JS fallback; update browser; remove --wasm for that audience.
  • Error: “Unexpected section or invalid magic number for .wasm”

    • Cause: Wrong MIME or double-compressed asset.
    • Fix: Set Content-Type: application/wasm. Use proper Brotli/gzip with correct headers. Avoid serving text mode.
  • Jank at first paint

    • Cause: Large fonts, too many synchronous initializers, no code splitting.
    • Fix: Preload fonts, defer non-critical setup, add deferred imports.
  • Memory growth over time

    • Cause: Missed disposals (streams, controllers, images), retaining large lists.
    • Fix: Audit lifecycle disposals, adopt AutoDispose providers, use Finalizer for JS-backed resources, use LRU caches with eviction.
  • CanvasKit on low-end GPUs

    • Cause: WebGL fallback or driver quirks causing slow draws.
    • Fix: Test --web-renderer html. Consider simpler effects, cache rasterized images.

CI/CD tips for Flutter web + WASM

  • Produce both builds:

    flutter build web --release --wasm --web-renderer canvaskit -t lib/main.dart -o build/web_wasm
    flutter build web --release --web-renderer html -t lib/main.dart -o build/web_js
  • Upload with immutable caching (CDN). Keep deployable artifacts versioned.

  • Smoke test on real browsers in CI (Playwright or Puppeteer) for navigation, forms, and critical flows.

Key takeaways

  • Flutter WebAssembly WASM (WasmGC) delivers real wins for startup time, CPU-bound logic, and memory behavior - especially when paired with web-smart optimizations.
  • Treat WASM as a runtime change, not a renderer change. Still benchmark html vs canvaskit.
  • Lean into web discipline: correct MIME types, Brotli, cache-immutable assets, and preloads.
  • GC-aware patterns matter: avoid churn, use Finalizer for JS-backed resources, and aggressively dispose.
  • Start with a two-build strategy (WASM + JS) until your telemetry shows safe coverage.
  • Combine deferred loading, Riverpod/Dio patterns, and careful rebuild control to keep frames smooth.

Next steps:

  • Benchmark your current app with --wasm vs JS on your target devices and browsers.
  • Introduce deferred imports for your heaviest routes.
  • Add server-side headers for .wasm and consider COOP/COEP if you plan to adopt future WASM threads.
  • Monitor performance in real time: Use Appxiom to trace post-deployment WASM performance, detect frame drops, and monitor memory regression across different browser targets.

With the right migration plan and a few targeted optimizations, Flutter WebAssembly WASM can unlock faster, more predictable web experiences - and make your Flutter web app truly production ready.