Speed up Flutter web app with WASM Link to heading

Starting with Flutter version 3.24, WebAssembly (WASM) has been introduced as a compilation target for web applications. In this article, we will benchmark a CPU-intensive Flutter web app with and without WASM to evaluate performance improvements.

Understanding WASM in Flutter Link to heading

By default, Flutter web apps transpile Dart code into JavaScript (JS) for browser compatibility. With the –wasm flag, the code is additionally compiled into a WASM file, enabling faster execution in supported browsers. This approach can significantly improve performance for computationally intensive tasks.

Limitation of WASM in Flutter Link to heading

  • Browser Support: Only browsers using the V8 engine (e.g., Chrome and Edge, version 119 or later) currently support WASM with Garbage Collection (WasmGC). iOS browsers are excluded because they are limited to WebKit.
  • Limited Memory: As of now, Chrome supports up to 4GB of memory for WASM modules.
  • Library Compatibility: Some Dart libraries like dart:html and package:js are not supported when using WASM.
  • Serving Configuration: Flutter web applications won’t run with WASM unless the server is configured to send specific HTTP headers. See here for more details.
  • Fallback Mechanism: If WASM is unsupported, browsers will still load the JavaScript version, ensuring compatibility.

Despite these limitations, using the –wasm flag is recommended for its performance benefits in supported environments.

Building a Benchmark App Link to heading

We’ll create a minimal Flutter web app that:

  1. Allows users to drag and drop a .gz compressed file.
  2. Decompresses the file client-side in the browser.
  3. Provides the decompressed file for download along with the time taken for decompression.

Since dart:io is unavailable on web platforms, we will use the archive package, which is implemented in pure Dart. Below is minimal implementation of the app

import 'package:flutter/material.dart';
import 'package:flutter_dropzone/flutter_dropzone.dart';
import 'package:archive/archive.dart';
import 'dart:typed_data';
import 'package:file_saver/file_saver.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Dropzone',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const HomePage(),
    );
  }
}

const isRunningWithWasm = bool.fromEnvironment('dart.tool.dart2wasm');

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  String message = 'Drop a .gz file here';
  bool isDecompressing = false;
  String decompressionTime = '';
  late DropzoneViewController controller;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Flutter Dropzone')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
              width: 300,
              height: 200,
              decoration: BoxDecoration(
                border: Border.all(color: Colors.blue),
                borderRadius: BorderRadius.circular(10),
              ),
              child: Stack(
                children: [
                  DropzoneView(
                    onCreated: (controller) => this.controller = controller,
                    onDropFile: _handleDrop,
                  ),
                  Center(child: Text(message)),
                ],
              ),
            ),
            const SizedBox(height: 20),
            if (isDecompressing) const CircularProgressIndicator(),
            if (decompressionTime.isNotEmpty) Text(decompressionTime),
            if (isRunningWithWasm) const Text('Running on WebAssembly'),
          ],
        ),
      ),
    );
  }

  Future<void> _handleDrop(dynamic event) async {
    setState(() {
      message = 'Processing dropped file...';
      isDecompressing = false;
    });

    try {
      final Uint8List file = await controller.getFileData(event);
      final String fileName = await controller.getFilename(event);

      if (!fileName.endsWith('.gz')) {
        throw Exception('Please drop a .gz file');
      }

      setState(() {
        message = 'Decompressing...';
        isDecompressing = true;
      });

      final Stopwatch stopwatch = Stopwatch()..start();

      // Decompress the file
      final Uint8List decompressedData = GZipDecoder().decodeBytes(file);

      stopwatch.stop();

      setState(() {
        decompressionTime = 'Decompression time: ${stopwatch.elapsedMilliseconds} ms';
        isDecompressing = false;
        message = 'File decompressed successfully';
      });

      // Save the decompressed file
      await downloadFile(decompressedData, fileName.replaceAll('.gz', ''));
    } catch (e) {
      setState(() {
        message = 'Error: ${e.toString()}';
        isDecompressing = false;
      });
    }
  }
}

Future<void> downloadFile(Uint8List data, String fileName) async {
  await FileSaver.instance.saveFile(
    name: fileName,
    bytes: data,
  );
}

Serving the Benchmark App Link to heading

Now that we have the app, let’s first build the same app with and without WASM support.

# build without wasm support
flutter build web --release
# move to build/no-wasm
mv build/web build/no-wasm

# build with wasm support
flutter build web --release --wasm
# move to build/wasm
mv build/web build/wasm

Then, we will use the following script to serve the app w/o WASM support

# serve_no_wasm.py
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles


app = FastAPI()


# Mount the Flutter web app directories
app.mount(
    "/", StaticFiles(directory="build/no-wasm", html=True), name="no_wasm"
)


@app.middleware("http")
async def add_cors_headers(request, call_next):
    response = await call_next(request)
    response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
    response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
    return response

We will use a similar script to serve the same app with WASM support

--- serve_no_wasm.py 2025-02-09 18:25:10.225271867 -0500
+++ serve_wasm.py 2025-02-09 18:25:08.696304118 -0500
@@ -6,9 +6,7 @@
 
 
 # Mount the Flutter web app directories
-app.mount(
-    "/", StaticFiles(directory="build/no-wasm", html=True), name="no-wasm"
-)
+app.mount("/", StaticFiles(directory="build/wasm", html=True), name="wasm")
 
 
 @app.middleware("http")

Let’s run both the apps with port 8000 and 8001

uvicorn serve_no_wasm:app --host 0.0.0.0 --port 8000
uvicorn serve_wasm:app --host 0.0.0.0 --port 8001

Running the Benchmark Link to heading

Below shows comparison of running each app to decompress the same file side by side. Note that on the left is the app running with WASM (as indicated on the app) vs the right is the same app running with JS (i.e., no WASM)

Running WASM (left) vs JS side by side

  • JS on Firefox: 9.3s
  • JS on Safari: 5.4s
  • JS on Chrome: 6.8s
  • WASM on Chrome: 3.9s

Conclusion Link to heading

WASM brings significant performance to Flutter web apps (75% faster execution for this test), making it a game-changer for CPU-intensive tasks like file decompression or real-time data processing. While there are limitations in browser support and library compatibility, its fallback mechanism ensures that apps remain functional on all platforms. By leveraging WASM, developers can build faster and more responsive web applications. Best of all, all you need is to just add the –wasm flag!

Resources Link to heading