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:
- Our Rust code will use the
wasm-bindgen
package to expose functions to be converted to Wasm; - We use
wasm-pack
to compile our Wasm module, with the output being two important files - a.wasm
file and a.js
file; - The
.js
file has public functions that allow us to call thewasm
functions - we call them bindings; - Our app will communicate with the JavaScript bindings to be able to access the WebAssembly code.
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>
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:
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:
- Annotate our file with
@JS()
and use it as a library - Annotate any functions we want with
@JS()
and add theexternal
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:
- It accepts an
input
as a String to get the path for the.wasm
file - It is an
async
function, so the return type in Dart has to be aFuture
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:
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:
- Remove the
export
fromexport default init;
, at the end of the file; - Remove the
export
from everyfunction
.
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:
- Create a new project for our module;
- Use (and potentially learn) another language, such as Rust;
- Manipulate the generated
.js
file to be used by our Flutter project; - 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 👇
And for other articles, check the rest of the blog! Blog - Gonçalo Palma