Gonçalo Palma
November 7, 2021

Using WebAssembly in Flutter Web

Photo by Jay Heike on Unsplash

Today, Flutter Web can be used as an alternative to creating mobile applications that need to undergo the rules and constraints of the Apple and Google app stores. This is why many clients are shifting into having web-only apps, something that Flutter can do quite effectively.

The efforts of the Flutter team and community were quite fast in porting the majority of popular plugins, such as Google Maps, to make it compatible with Flutter Web. However, there’s one key feature missing - Isolates.

Why is this so important?

Without Isolates, we cannot run complex operations client-side. Tasks such as optimizing images before sending them to the server, doing complex cryptographic operations, or filtering and querying large lists of objects might take so long that it blocks the UI and makes the app unresponsive for long periods.

Instead of trying to discover a solution purely based on Flutter, let’s back up one step and see the whole picture. Flutter Web is built on top of the Web platform, which uses JavaScript. That means that if we find a solution that allows us, in JavaScript, to run code in a separate worker or thread, then we can try to find a way for Dart to communicate with JavaScript and use it too, as we do with PlatformChannels to communicate between Dart and native iOS and Android.

Alternatives to Isolates in the Web

One alternative that is used to run code parallel to our application in Web is using WebWorkers. WebWorkers will run code that is going to be executed in parallel to the UI thread. As with isolates, the communication between a worker and the main thread is going to be done via messages, with the onMessage callback on the client to receive and the postMessage() method on the worker.

However, in recent years there has been a more attractive solution that can aid us on our problem - WebAssembly (Wasm). As with WebWorkers, it allows us to run code in parallel to the application’s UI thread, but with a monumental difference - WebAssembly code will have a near-native performance on the browser. So not only do we run code in parallel but we are also having a big boost in performance! To learn more about how we get this performance increase, you can read Yair Cohen’s article Web Assembly Deep Dive – How it Works, And Is It The Future?.

To create a Wasm module we can use many languages, but for this article, we will focus on Rust due to its simplicity and the amount of tooling already available that let us easily create Wasm modules.

Rust and WebAssembly - How does it work?

Instead of using just vanilla Rust to create our Wasm modules, we’re going to use a tool called wasm-pack that is going to help us with the workflow of setting up a Wasm project. wasm-pack will also allow us to compile our code into WebAssembly so that it is used by our app.

In a nutshell, this is what happens when we use wasm-pack to create a Web Wasm module:

  1. Our Rust code will use the wasm-bindgen package to expose functions to be converted to Wasm;
  2. We use wasm-pack to compile our Wasm module, with the output being two important files - a .wasm file and a .js file;
  3. The .js file has public functions that allow us to call the wasm functions - we call them bindings;
  4. Our app will communicate with the JavaScript bindings to be able to access the WebAssembly code.

Using Wasm modules

To exemplify this, let’s create a simple Wasm module.

First, we need to create a new wasm-pack project using:

$ wasm-pack new <folder-name>

Create Wasm project in Rust

This will create a template project for us, including a Cargo.toml, where we set the dependencies for our project, and a lib.rs, which is where our main code will reside.

.
├── Cargo.toml
├── LICENSE_APACHE
├── LICENSE_MIT
├── README.md
├── src
│   ├── lib.rs
│   └── utils.rs
└── tests
    └── web.rs

We are going to create a Rust function that accepts two integers, u8, and will return the addition of them.

pub fn add(a : u8, b : u8) -> u8 {
    return a + b;
}

If we try to compile this to WASM, we will see that the add function will not be included in the .js file, and this is because we didn’t “tell” Rust to compile this to our WASM module. To do so, we need to add the Macro wasm_bindgen:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(a : u8, b : u8) -> u8 {
    return a + b;
}

With the Macro set in place, we can try to use the build command for the target web, since it will output the files that we will need for our Flutter Project - a .js bindings file and a .wasm file:

Compiling Wasm module

From the output message, we learn that the contents of the build are inside the pkg folder:

pkg
├── README.md
├── addition.d.ts
├── addition.js
├── addition_bg.wasm
├── addition_bg.wasm.d.ts
└── package.json

Since our Flutter app is not using TypeScript, let’s focus on the addition.js file.

This file has 3 functions:

let wasm;

/**
* @param {number} a
* @param {number} b
* @returns {number}
*/
export function add(a, b) {
    var ret = wasm.add(a, b);
    return ret;
}

async function load(module, imports) {
    /// ...
}

async function init(input) {
    /// ...
    const { instance, module } = await load(await input, imports);

    wasm = instance.exports;
    init.__wbindgen_wasm_module = module;

    return wasm;
}

export default init;

The init function will initialize the bindings, calling the load and setting the wasm global variable. This variable will be used in the add JavaScript function as a way to call the Wasm module’s add function, serving as a bridge between the app and WebAssembly.

Now that we have our Wasm module compiled with the JavaScript bindings, we just need to call these functions from within our application. However, that poses a question - how do we call JavaScript code in Dart?

Using JavaScript code in Dart

Truth is, long before Flutter, Dart was used to create web applications with AngularDart, which means that since almost its inception, Dart has been able to interact with JavaScript code on the browser.

For example, if we need to show an alert using JavaScript, we can access the window variable and use the alert function:

import 'dart:html';

void callAlert(String message) {
  window.alert(message);
}

As with the alert function, there are already plenty of JavaScript functions that we can call using the window global variable.

Furthermore, with dart:html we can also access the DOM to, for example, add our own <script> element, as seen in my article - Flavors in Flutter Web.

The reality is, we want more than calling pre-defined JavaScript functions, we need to call the JavaScript functions that were created for the Wasm module bindings. And for that, we can use the js package. This package lets us annotate Dart code to tell the Dart to JavaScript compilers to create the necessary code so that Dart and JavaScript can communicate.

To use it we will need to:

  1. Annotate our file with @JS() and use it as a library
  2. Annotate any functions we want with @JS() and add the external keyword to them:
@JS()
library addition;

import 'package:js/js.dart';

// Calls invoke JavaScript `JSON.stringify(obj)`.
@JS('add')
external int add(int a, int b);

The previous code will allow us to call the add function from our WASM module. Now we just need to import this file and call this add function to use our Wasm module.

Case Study - RSA Encryption in Web

Creating the Rust Module

So far we’ve only created a simple function that shows us how to add two variables. However, we don’t WebAssembly to help us add two variables since this is not a task that can potentially block our Flutter Web applications. This is why in this section we’re going to create a simple application that will create an RSA KeyPair and encrypt a message from the user with the public key, an operation that can potentially be time-consuming on the client side.

We’ll create the same functionality using Dart and WebAssembly to be able to compare the results at the end of the article.

To generate an RSA KeyPair and encrypt a message, we can use the crypton package and use the following code:

String encryptMessage(String secret) {
  RSAKeypair rsaKeypair = RSAKeypair.fromRandom(keySize: 2048);

  return rsaKeypair.publicKey.encrypt(secret);
}

When we run this code to encrypt the message ”A message", it will take 106 seconds to complete the operation. With Dart being single-threaded, it means that during these 106 seconds the app will become unresponsive and the user’s only options are to either quit the application or wait almost 2 minutes to get the result.

Now let’s try to do the same thing with WebAssembly.

After creating our new project called rust_crypto_module, we search for a new package to create RSA keys and encrypt messages. In the case of Rust, we use the crates.io and lib.rs websites to search for new packages (or crates, as they call them in the Rust world) and we add them to our project in the Cargo.toml file.

There’s just one problem - not all Rust packages are compatible with wasm-pack. Unfortunately, crates.io does not show if a dependency, or crate, is compatible with wasm-pack, however, lib.rs has that functionality if we search for wasm - lib.rs/wasm. In this list of crates, we find one that fits our needs - rsa.

We start by adding it to our Cargo.toml file, along with specific versions of its dependencies, rand and getrandom to safely compile to WASM:

[dependencies]
rsa = "0.5.0"
rand = "0.8.4"
getrandom = { version = "0.2", features = ["js"] }
wasm-bindgen = "=0.2.73"

Next, we run cargo build to get the new dependencies and we can create our Rust function:

#[wasm_bindgen]
pub fn encrypt(data: String) -> Vec<u8> {
    let mut rng = OsRng;
    let bits = 2048;
    let priv_key = RsaPrivateKey::new(&mut rng, bits).expect("failed to generate a key");
    let pub_key = RsaPublicKey::from(&priv_key);

    // Encrypt
    let enc_data = pub_key
        .encrypt(&mut rng, PaddingScheme::PKCS1v15Encrypt, &data.as_bytes())
        .expect("failed to encrypt");

    return enc_data;
}

After we compile it with wasm-pack build -t web, we can verify that the encrypt function is inside our .js bindings file, which means that we can create our Dart code to interact with it.

But first, let’s add the .js and .wasm files to our Flutter application. To do that, we go inside the /web folder in our Flutter project, create a new pkg folder and paste both files:

web
├── favicon.png
├── icons
│   ├── Icon-192.png
│   ├── Icon-512.png
│   ├── Icon-maskable-192.png
│   └── Icon-maskable-512.png
├── index.html
├── manifest.json
└── pkg
    ├── rust_crypto_module.js
    └── rust_crypto_module_bg.wasm

We also need to add the .js file to our index.html file so that our application knows it needs to be loaded. This can be done by adding a <script> tag inside our <body>:

<script src="pkg/rust_crypto_module.js" defer> </script>

Using js to call our JavaScript functions

As with the addition example, we’ll need to create a new file, annotate it with @JS() and add all the necessary functions. But, one thing that we forgot in the addition example, is that we did not include the init function.

This function has two issues with it:

  1. It accepts an input as a String to get the path for the .wasm file
  2. It is an async function, so the return type in Dart has to be a Future

For the first issue, let’s closely examine the first few lines of code:

async function init(input) {
    if (typeof input === 'undefined') {
        input = new URL('rust_crypto_module_bg.wasm', import.meta.url);
    }
    
    //...
    return wasm;
}

We already know that our rust_crypto_module_bg.wasm file will be inside the pkg folder, so we can very easily define a value for input without setting it as an argument for the function:

async function init() {
    input = "pkg/rust_crypto_module_bg.wasm";
    
    //...
    return wasm;
}

By removing input as an argument, the Flutter application no longer needs to know where the .wasm file is located.

The next issue is the fact that this is an async function.

Unfortunately, JavaScript does not have the Future type as we have in Dart to deal with asynchronous functions. Instead, it uses Promises. This means that we will need to find a way to cover a JavaScript Promise to a Future in Dart. Thankfully, many projects in GitHub, such as some Flutter plugins needed to accomplish this as well, so we can take inspiration from their code.

First, we need to use js to declare our encrypt and init function but also our Promise type:

@JS()
library bindings;

import 'package:js/js.dart';

@JS()
abstract class Promise<T> {
  external factory Promise(
      void executor(void resolve(T result), Function reject));
  external Promise then(void onFulfilled(T result), [Function onRejected]);
}

@JS('init')
external Promise<void> init();

@JS('encrypt')
external List<int> encrypt(String data);

With the Promise class created, we can now convert it to a Future using the promiseToFuture function. We are going to use it to verify if the init function has been called before we call encrypt:

bool _isInit = false;

Future<void> checkInit() async {
  if (!_isInit) {
    await promiseToFuture(init());
    _isInit = true;
  }
}

The checkInit function will be used as the first line of every WASM function to make sure that the WASM module is initialized correctly.

Finally, we will just need to create call our encrypt function from within our project:

Future<List<int>> rsaEncryption(String data) async {
  await checkInit();
  return encrypt(data);
}

We just need to run our Flutter application to verify that everything works correctly.

However…

The export errors

If we run our application and call our code, we will be greeted by the following error: JavaScript export errors

As we have seen, we have the JavaScript keyword export is used in multiple places in our .js bindings file.

The easiest solution will be to remove the export keyword from our .js file:

  1. Remove the export from export default init;, at the end of the file;
  2. Remove the export from every function.

In the end, we’ll have a file that will resemble the following:

let wasm;

async function load(module, imports) {
    // ...
}

async function init() {
    var input = "pkg/rust_crypto_module_bg.wasm";
    // ... 

    return wasm;
}


/**
* @param {string} data
* @returns {Uint8Array}
*/
function encrypt(data) {
    // ...
}

And finally, we can run our project without any errors and call our Rust WASM function.

Conclusion and results

The results are as follow:

Language Time (s)
Dart 106
Rust (Wasm) 1.5

This means that we reduced the computation time to 1%.

So not only are we running code in parallel, but we are also vastly increasing the performance of our application by choosing to create a WebAssembly module.

Despite that, we must take these results with a grain of salt - we did indeed optimize our Flutter Web application using WebAssembly, but does the amount of time and effort justify using a Wasm module? Or should we try to look for other alternatives?

In Flutter Mobile, we simply called a compute function that took all the heavy work, spawned a new thread, and delivered us a result in the form of a message.

On the other hand, in Flutter Web we needed the following to create a WebAssembly module and communicate with it:

  1. Create a new project for our module;
  2. Use (and potentially learn) another language, such as Rust;
  3. Manipulate the generated .js file to be used by our Flutter project;
  4. Create the necessary JavaScript bindings with the js package.

This process not only takes a considerate amount of time, but it also means that we need to now maintain two different projects at the same time.

This leads us to the conclusion:

Using WebAssembly is not going to be the silver bullet that solves all the performance issues of our Flutter Web application. If we are doing too much of the heavy-lifting on the client, then we should consider pushing those operations to a backend service, such as the ability to filter and query large amounts of data, or even question if what you are introducing to your Flutter Web application is a necessary feature or it can be mobile and desktop-only functionality.

But, if we need to optimize a core feature of our application that needs to be on the client-side, then using WebAssembly can take our app to the next level, and we already have many companies that use it in the apps, such as Discord and Figma

The Dart team has also done some experiments with WebAssembly, which you can read more in the article - Experimenting with Dart and Wasm.

If you want to see the RSA encryption app, you can check the following GitHub repository:

https://github.com/Vanethos/rust-wasm-example-rsa

Want to get the latest articles and news? Subscribe to the newsletter here 👇

Diving Into Flutter

In Diving into Flutter we will discover the Flutter Framework, bit by bit.

From the BuildContext, to pubspec files, going through exotic backend solutions, there won't be a stone left unturned.

Each week I'll also share one or two links for interesting resources I found around the Web

And for other articles, check the rest of the blog! Blog - Gonçalo Palma

Want to get the latest articles and news? Subscribe to Diving Into Flutter

Hey! I'm Gonçalo Palma! 👋

I often share Deep Diving articles on Flutter 💙