Perché WebAssembly cambia le regole del gioco
Per anni Flutter Web ha avuto un problema strutturale: il codice Dart veniva compilato in JavaScript (dart2js), un linguaggio dinamico eseguito da un motore che deve profilare, ottimizzare e talvolta deottimizzare il codice a runtime. Il risultato erano frame time poco prevedibili, soprattutto durante animazioni complesse o quando il garbage collector del browser entrava in azione.
Con dart2wasm il codice Dart viene compilato in WebAssembly sfruttando l'estensione WasmGC, che permette di usare il garbage collector nativo del browser invece di spedirne uno dentro il bundle. Il codice è tipizzato staticamente e compilato AOT: niente warm-up, niente deottimizzazioni, prestazioni molto più costanti.
Il supporto è stabile dalla release 3.22 ed è maturato parecchio: oggi flutter build web --wasm produce sia il bundle WebAssembly sia il fallback JavaScript, e il bootstrap sceglie automaticamente la variante migliore in base al browser.
Requisiti e stato dei browser
WasmGC è supportato da:
- Chrome / Edge 119+
- Firefox 120+
- Safari 18.2+ (supporto arrivato più tardi, verifica sempre la tua base utenti)
Sui browser che non supportano WasmGC il loader generato da Flutter carica il bundle JavaScript: non rischi la pagina bianca, ma il costo è che stai servendo due artefatti.
Il primo build
In sviluppo puoi lavorare direttamente in Wasm:
flutter run -d chrome --wasm
Per la produzione:
flutter build web --wasm --release
L'output in build/web conterrà main.dart.wasm, main.dart.mjs (il glue code), il fallback main.dart.js e il file flutter_bootstrap.js che gestisce la selezione automatica.
Se vuoi verificare a runtime su quale target stai girando, Flutter espone una costante dedicata:
import 'package:flutter/foundation.dart';
void logTarget() {
if (kIsWeb) {
debugPrint(kIsWasm ? 'Runtime: WebAssembly' : 'Runtime: JavaScript');
}
}
kIsWasm è una costante di compilazione: il compilatore elimina i rami morti, quindi puoi usarla anche per includere codice diverso nelle due varianti senza penalizzare il bundle.
Il renderer: skwasm
Compilare in Wasm non riguarda solo il tuo codice Dart: anche il motore grafico cambia. Flutter Web oggi offre due renderer:
- canvaskit: Skia compilato in WebAssembly, pilotato da JavaScript. È il default del build JS.
- skwasm: la pipeline grafica pensata per il target Wasm, con supporto al rendering multi-thread tramite web worker.
Per sfruttare il multi-threading di skwasm il documento deve essere in stato di cross-origin isolation, cioè servito con gli header:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Senza quegli header non si rompe nulla: viene usata la variante single-thread di skwasm. Attenzione però: la cross-origin isolation ha effetti collaterali (iframe di terze parti, script esterni, widget di pagamento) perché richiede che ogni risorsa cross-origin dichiari Cross-Origin-Resource-Policy. Abilitala solo dopo aver mappato le dipendenze esterne della pagina.
L'ostacolo vero: l'interoperabilità con JavaScript
Qui sta la parte di lavoro reale di una migrazione. In Wasm non esistono dart:html, dart:js e la vecchia package:js basata su annotazioni dinamiche. Tutto passa da dart:js_interop (interop statica) e da package:web, che sostituisce dart:html con binding generati dalle specifiche W3C.
Se il tuo pubspec.yaml o quello di una dipendenza importa dart:html, il build Wasm fallisce.
Da dart:html a package:web
// Prima
import 'dart:html' as html;
void save(String token) {
html.window.localStorage['token'] = token;
}
// Dopo
import 'package:web/web.dart' as web;
void save(String token) {
web.window.localStorage.setItem('token', token);
}
I binding di package:web seguono fedelmente le API DOM: niente più mappe idiomatiche alla Dart, ma setItem/getItem, querySelector, addEventListener e così via.
Chiamare una funzione JavaScript
import 'dart:js_interop';
@JS('navigator.clipboard.writeText')
external JSPromise<JSAny?> _writeText(JSString text);
Future<void> copyToClipboard(String text) async {
await _writeText(text.toJS).toDart;
}
Due dettagli importanti:
- I tipi che attraversano il confine devono essere JS types (
JSString,JSNumber,JSBoolean,JSObject,JSArray...). Le conversioni avvengono con.toJSe.toDart. - Una
JSPromisediventa unaFuturecon.toDart.
Avvolgere una libreria JS con le extension type
Le extension type di Dart 3 sono lo strumento consigliato per creare wrapper a costo zero:
import 'dart:js_interop';
@JS('Analytics')
extension type Analytics._(JSObject _) implements JSObject {
external factory Analytics(JSString apiKey);
external void track(JSString event, JSObject properties);
external JSPromise<JSAny?> flush();
}
extension AnalyticsX on Analytics {
void trackEvent(String name, Map<String, String> props) {
final jsProps = JSObject();
for (final entry in props.entries) {
jsProps.setProperty(entry.key.toJS, entry.value.toJS);
}
track(name.toJS, jsProps);
}
}
Il wrapper non alloca nulla a runtime: è solo una vista tipizzata su un JSObject.
Esportare una funzione Dart verso JS
import 'dart:js_interop';
void onFlutterReady(String route) {
debugPrint('Flutter pronto su $route');
}
void registerCallbacks() {
globalContext.setProperty(
'onFlutterReady'.toJS,
onFlutterReady.toJS,
);
}
Import condizionali: attenzione alle vecchie condizioni
Il classico pattern con dart.library.html non funziona più in Wasm, perché quella libreria non esiste nel nuovo target. La condizione corretta è dart.library.js_interop:
// storage.dart
export 'storage_io.dart'
if (dart.library.js_interop) 'storage_web.dart';
// storage_io.dart
import 'dart:io';
Future<String> readConfig() => File('config.json').readAsString();
// storage_web.dart
import 'package:web/web.dart' as web;
Future<String> readConfig() async =>
web.window.localStorage.getItem('config') ?? '{}';
Se mantieni anche il supporto a vecchie versioni di Flutter, puoi combinare le condizioni usando dart.library.io per il ramo nativo e lasciare il web come default.
Checklist di migrazione
- Audit delle dipendenze: cerca
dart:html,dart:js,package:jsnel tuo codice e in.dart_tool/package_config.json. Verifica su pub.dev che i package abbiano il tag WASM ready (pub.dev mostra esplicitamente la compatibilità). - Sostituisci
dart:htmlconpackage:webedart:jscondart:js_interop. - Aggiorna gli import condizionali a
dart.library.js_interop. - Sostituisci i plugin non compatibili: molti plugin ufficiali (shared_preferences, url_launcher, firebase) sono già aggiornati; per quelli abbandonati valuta un wrapper interop scritto a mano.
- Compila e testa:
flutter build web --wasm --releasee prova su Chrome, Firefox e Safari. - Misura: usa il pannello Performance di Chrome DevTools, non la sensazione a occhio.
Hosting: le configurazioni che fanno la differenza
Un errore frequente è servire il .wasm con il MIME type sbagliato: senza application/wasm il browser non può usare lo streaming compilation e la partenza rallenta sensibilmente.
Esempio per Nginx:
types {
application/wasm wasm;
}
location ~* \.(wasm|js|mjs)$ {
gzip_static on;
brotli_static on;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
# Solo se vuoi skwasm multi-thread:
# add_header Cross-Origin-Opener-Policy "same-origin";
# add_header Cross-Origin-Embedder-Policy "require-corp";
}
La compressione Brotli è particolarmente efficace sui binari Wasm: può ridurre il payload del 70-75%. Precomprimi gli asset in fase di build invece di comprimerli a ogni richiesta.
Quando conviene (e quando no)
Wasm conviene se:
- l'app ha animazioni continue, canvas, grafici o scroll di liste pesanti;
- la logica di business è intensiva (parsing, crittografia, calcoli, elaborazione di immagini);
- gli utenti usano browser moderni e tornano spesso (la cache ammortizza il download iniziale).
Wasm conviene meno se:
- l'app è essenzialmente una serie di form e liste leggere: il guadagno percepito è minimo;
- dipendi pesantemente da librerie JS di terze parti non incapsulabili;
- hai una quota rilevante di utenti su browser datati e non puoi permetterti di servire due bundle;
- il time-to-first-render è la metrica più critica e il payload iniziale è già al limite.
Un punto onesto da tenere presente: il bundle Wasm non è sempre più piccolo di quello JS, e con il fallback stai spedendo due artefatti sul server (anche se il client ne scarica solo uno). Il vantaggio è sul tempo di esecuzione, sulla stabilità dei frame e sulla prevedibilità, non necessariamente sul primo caricamento.
Conclusione
dart2wasm è oggi la direzione ufficiale di Flutter Web, e il lavoro di adeguamento che richiede — passare a dart:js_interop e package:web — è utile a prescindere: l'interop statica è più sicura, più veloce e meglio ottimizzabile dal compilatore anche nel build JavaScript.
Il consiglio pratico: inizia oggi la bonifica degli import legacy, anche se non attivi subito --wasm. Quando deciderai di passare, il build sarà questione di una flag invece che di un refactoring d'emergenza.