what we blog

Rust, WebAssembly & Web Workers for speed and profit

In a recent client project we had the chance to refactor an existing feature with Rust, WebAssembly & web workers. In an internal web application used by logistics teams in multiple countries, one essential feature is the generation of QR codes. They consists of different types of information & had to be created on-time with the most recent data.

One of the issues was that sometimes to ensure the QR codes were correct a manual intervention by developers was required, involving a somewhat frickle & slow process in order to re-generate these codes. Usually this did not happen that often, most of the time the existing work flow was ok, but in certain situations the re-generation of many thousands of codes meant that the affected teams had to wait for a few hours. This also involved planning and upfront communication with the affected teams.

The task was to find a solution to replace the existing process with one that required less or no manual intervention, or to find an alternative to avoid the re-generation in general. The approach we came up with was to generate these QR codes as late as possible in the process to incorporate the most recent information. After discussing several server side approaches we decided to generate the codes via Rust & WebAssembly (WASM) in the front end part of the application.

A prototype was in place within a few days, the integration into the web application required a bigger chunk of time. With a working prototype we figured out early on that this was not only a promising approach, but it would also make sure certain issues would be fixed along the way.

Many thanks to Stefano who gave Ana & me the time to try this approach. A huge thanks to Ana who did most of the initial WebAssembly & JS work. hugs

Introduction

The last year has shown a lot of effort by the Rust community put into the WebAssembly eco system which makes Rust one of the preferred languages. Alongside better tools & libraries there is a lot more information online on WebAssembly, which makes it more accessible for a bigger audience. Early last year Ana digged into WebAssembly with his blog post The Path to Rust on the Web. While it is still revelant, a lot of things have changed, especially the tool chains are a bit different now. Since the end of last year Rust brings now its own native target wasm32-unknown-unknown for WebAssembly, therefore Emscripten is not required anymore when writing Rust for WASM.

A nice side effect of using this specific target is, the final code size of the WASM module is smaller compared to the emscripten target (see this Mozilla post for more details). The Rust community also invests a good amount of effort into tools & libraries, for example the crates wasm-bindgen, wasm-pack, which make writing Rust for WebAssembly a lot more convenient.

In order to see an example on how some of these tools work, we will come back to the QR code task described above. For this we will create a new Rust project from scratch, learn how to compile the code to a WebAssembly module using wasm-bindgen, how to call the functionality in JavaScript and finally see how this can be integrated into a web worker. This article also provides a few references at the end if you want to learn more about Rust & WebAssembly in general.

Setup

The first thing to prepare is to install the new WASM target wasm32-unknown-unknown. The easiest way to install the target is to use rustup. To install the target on your machine run:

$ rustup target add wasm32-unknown-unknown

Then we create a new Rust project with cargo.

$ cargo new qr_wasm --lib
$ cd qr_wasm
$ rustup override set nightly

This creates a new skeleton library project named qr_wasm. It is best to use a recent Rust version. Some of the used features require the most recent Rust version. Therefore we use Rust nightly. Things may break at this point as the WebAssembly ecosystem is still quite young & evolving. It may result in a broken build. Update in crates may change the API, or crates require a more recent Rust version. One of the most remarkable aspects of the Rust community is how it addresses issues in a very fast & transparent manner. Often times issues are fixed right away, most likely within the same day of report.

If there are any issues with the given examples, please write me an email (sebastian.ziebell@asquera.de)

Project

Let's start the example by extending the project setup. Add the following lines to the Cargo.toml after the [package] block.

[lib]
crate-type = ["cdylib"]

[dependencies]
qrcode = { version = "0.8", default-features = false, features = ["svg"] }
wasm-bindgen = "= 0.2.15"

[build-dependencies]
wasm-bindgen-cli = "= 0.2.15"

This defines our Rust project to be a dynamic library. It also sets two dependencies, the qrcode crate that we will use to generate QR codes as SVG, the main purpose of our function. The other dependency is the wasm-bindgen crate. This is one of the younger libraries that is becoming more essential in the Rust / WebAssembly eco system, because it takes care of the functional layer between JavaScript & WebAssembly. It auto-generates a lot of the necessary glue code, we otherwise would have to write on our own. The Cargo.toml also lists a build dependency block to install the wasm-bindgen-cli, a CLI tool which is part of the wasm-bindgen crate we will use.

When we initially implemented a running version to generate QR codes in the client application we also handled the memory management via FFI C functions (see Ana's blog post). The library wasm-bindgen is taking care of this for us in the example. Using custom memory management is still a viable option as benchmarking the different versions has shown us. For this reason the WebAssembly module released in production uses a faster version without the generated wasm-bindgen code.

Check the Github repository for more information, it contains a few different examples.

Nevertheless I highly advise to take a look into the wasm-bindgen crate (see its documentation), because it makes the interaction between WASM & JavaScript so much easier to use & less error prone. One of the areas where the crate makes things easier is the conversion of data types. The current WebAssembly standard defines only 4 data types (2 integer & 2 float types), by using this crate other types, e.g. strings, can be converted between WASM & JS.

Let's see the Rust code that generates the QR codes. The qrcode crate supports a way to generate a QR code as a SVG HTML string. Replace the src/lib.rs file with the following content:

extern crate wasm_bindgen;
extern crate qrcode;

use wasm_bindgen::prelude::*;

use qrcode::render::svg;
use qrcode::QrCode;

#[wasm_bindgen]
pub fn qrcode(arg: &str, width: u32, height: u32) -> String {
    let result = QrCode::with_error_correction_level(arg, qrcode::EcLevel::Q)
        .map(|code| code.render::<svg::Color>()
            .max_dimensions(width, height)
            .min_dimensions(width, height)
            .build()
        );

    match result {
        Ok(v) => v,
        Err(e) => format!("{}", e),
    }
}

The function qrcode is pretty straight forward, it takes three parameters—the string (parameter arg) to encode & the dimensions of the generated SVG in pixels (parameters width & height)—to the underlying function to create a QR code with error correction. For our purposes we return either an error message as a String or the QR Code as a SVG string.

The interesting part here is how little is required to actually expose a function in Rust to WASM. The first line makes the wasm-bindgen crate available, the second line provides the macro #[wasm_bindgen], which is used to expose the function in the WASM module. For a more detailed documentation on what the crate has to offer, please see The wasm-bindgen Guide.

Let's compile our code for the WASM target in release mode:

$ cargo build --target wasm32-unknown-unknown --release
   Compiling proc-macro2 v0.4.15
   Compiling unicode-xid v0.1.0
   Compiling version_check v0.1.4
   Compiling serde v1.0.75
   Compiling ryu v0.2.6
   Compiling wasm-bindgen-shared v0.2.19
   Compiling itoa v0.4.2
   Compiling cfg-if v0.1.5
   Compiling checked_int_cast v1.0.0
   Compiling lazy_static v1.1.0
   Compiling log v0.4.4
   Compiling qrcode v0.8.0
   Compiling quote v0.6.8
   Compiling syn v0.14.9
   Compiling serde_json v1.0.26
   Compiling serde_derive v1.0.75
   Compiling wasm-bindgen-backend v0.2.19
   Compiling wasm-bindgen-macro-support v0.2.19
   Compiling wasm-bindgen-macro v0.2.19
   Compiling wasm-bindgen v0.2.19
   Compiling qr_wasm v0.1.0 (.../qr_wasm)
    Finished dev [unoptimized + debuginfo] target(s) in 36.44s

This compiles the code to the qr_wasm.wasm file in folder ./target/wasm32-unknown-unknown/release. Previously the wasm32-unknown-emscripten target was used to generate the .wasm and .js files for us. The purpose of the JS file is to make the functionality from the WASM module accessible on the JavaScript side. Accompanying the wasm-bindgen crate is a CLI tool. The purpose of this tool is to take the compiled WASM module & emit a replacement version of it, stripping all unnecessary information to only export what is needed. It reduces the size of the WASM file significantly.

To list all options of the wasm-bindgen CLI run:

$ wasm-bindgen --help

For our purposes we run the wasm-bindgen CLI with the following options.

$ mkdir -p dist
$ wasm-bindgen target/wasm32-unknown-unknown/release/qr_wasm.wasm --out-dir ./dist --no-modules --no-typescript

First the output folder dist is created where all generated files will be copied to. The output depends on your JavaScript environment, the --no-modules parameter is used to generate a plain JavaScript file, compatible with all browsers. Alternatively omit this option to generate a JavaScript file which can be imported as a ES 6 module, which is probably the best option when using modern build tools such as Webpack. The --no-typescript option prevents the emission of a TypeScript definition file (on by default). The generated JS file includes functionality on how to instantiate the WASM module.

$ tree ./dist
dist
├── qr_wasm.js
└── qr_wasm_bg.wasm

As can be seen wasm-bindgen generated two files, qr_wasm.js and qr_wasm_bg.wasm. The first file is the generated JavaScript code to instantiate the WASM module & to make the exposed functions available. The latter file is the transformed version of the initial WASM file. When you compare both the compiled and emitted version, file sizes are down from about 2.0mb to 73Kb (this may vary for you). To further reduce the size of the WASM module, a few options are available, for example by defining a specific release target with compiler options. For further information check out the online book of Rust and WebAssembly, in particular the chapter on Shrinking .wasm size.

To see the WASM module in the browser, create a HTML file named index.html in the root of the project.

<html>
  <head>
    <title>QRCode WASM</title>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
    <script src="dist/qr_wasm.js"></script>
    <script>
      window.addEventListener(`load`, function(event) {
        wasm_bindgen(`dist/qr_wasm_bg.wasm`)
          .then(() => {
            const svg = wasm_bindgen.qrcode("ABCD1234", 256, 256);
            document.body.insertAdjacentHTML(`beforeend`, svg);
          });
      });
    </script>
  </head>
  <body></body>
</html>

When the page loads an event listener will first instantiate the WASM module by calling wasm_bindgen() then call the qrcode function. To test the code run a HTTP server locally (e.g. Python 3 http.server or Node's http-server). Run the server in the root of the project:

$ http-server -p 8080

and open the browser at http://localhost:8080. The page should render a QR code.

This example itself is not very usable now, but we have everything set up. There is small caveat with new versions of the wasm-bindgen-cli tool. The generated JavaScript checks the availability of certain WebAssembly functions supported by the browser. It tries to call the most appropriate function. When you run the code in Firefox you may end up seeing nothing. Check the console log where you might see the following error message:

TypeError: Response has unsupported MIME type

This error happens when the application server that is used to serve all assets does not support the MIME type application/wasm yet. In this case either use a web server that offers support for this MIME type or use an older version of wasm-bindgen (as version 0.2.15 in our example). In recent versions this crate generates code that calls WebAssembly.instantiateStreaming that offers compilation while downloading the module, which is currently only supported in Firefox.

Newer versions of wasm-bindgen may fix this issue or provide appropriate options, therefore all the given examples use a specific version.

Web Workers

The current setup is all fine & working, but not particular useful for our purposes. The main idea is to generate a lot of QR codes in the background while the user of the application is still able to interact with the page.

Using a web worker is a common approach, because it offers a few advantages. For one the site itself keeps being responsive, while the heavy computation is done in the background. Furthermore this allows a kind of on-demand approach, whenever something expensive needs to be computated we send data to the web worker. Once the computation is done an event handler receives the appropriate response. Lastly multiple web workers can be used in the background to run either separate computational tasks or to use the available CPUs on the machine to run the computation in parallel. This also allows us to scale on machines with multiple CPU cores.

To set up a web worker a few things have to be considered. First we need to determine how to instantiate the WASM module. While implementing the QR code generation in our project we learnt that compiling & instantiating the WASM module in the main JavaScript thread and then sending the module to the web worker currently only works in Firefox. The process to transfer data from the main thread to the worker is by cloning the data structure. Unfortunately sending the instantiated WASM module to the worker means cloning a quite complex data structure, which is not supported in all browsers. For example Chrome displays the following error message:

#<Module> could not be cloned.

There are a few ways around this issue. First, fetch the WASM module, send the byte array to the web worker, then compile & instantiate it in the worker. Support to clone a ArrayBuffer is available in all modern web browsers. Unfortunately this means each web worker compiles & instantiates the same WASM module separately. For our use case this is ok, compiling a WASM module has a smaller overhead than loading a JavaScript file. Another approach, which is also used in our example, is to use the wasm_bindgen function to load the WASM module in the worker.

A web worker lives outside the JavaScript main thread in its own life cycle. Communication between the main thread and the worker is done by sending & receiving messages. The postMessage function is used to send a message, the onmessage event handler receives a message. An excellent introduction into web workers is The Basics of Web Workers.

Let's see how we can leverage this communication pattern in a web worker to our example. Let's write a JavaScript class to encapsulate the creation of a web worker, named QrCodeRenderer. The main advantage of using a JS class is to have a consistent interface while the implementation can be different. The following content defines the QrCodeRenderer.js file.

class QrCodeRenderer {
    constructor(worker_uri, wasm_uri) {
        this.worker = new Worker(worker_uri);
        this.worker.onmessage = this.handleMessage.bind(this);
        this.worker.postMessage({"init": wasm_uri});
    }

    render(data, width, height) {
        return new Promise((resolve, reject) => {
            document.addEventListener(`qr_code_rendered(${data})`, (event) => {
                resolve(event.detail.result);
            });
            this.worker.postMessage({
                data: data,
                width: width,
                height: height,
            });
        });
    }

    handleMessage(event) {
        document.dispatchEvent(new CustomEvent(`qr_code_rendered(${event.data.source})`, {
            detail: {
                result: event.data.result,
            }
        }));
    }
}

This class consists of three functions, a constructor, the render & handleMessage functions. The constructor creates a single web worker, assigns the local event handler function handleMessage to the onmessage callback of the worker. Lastly an init event is sent to the worker. When the QRCodeRenderer class is created the worker is set up to receive events.

As pointed out earlier communication between the web worker & the main JS thread is done by sending & receiving messages. We use the postMessage to send events to the worker, the handleMessage is used to receive messages from ther worker.

Let's have a look at the web worker file named QrCodeWebWorker.js.

class QrCodeWebWorker {
    constructor(wasm_uri) {
        importScripts("dist/qr_wasm.js");
        this.instance = wasm_bindgen(wasm_uri);
    }

    render(data, width, height) {
        return this.instance.then(() => wasm_bindgen.qrcode(data, width, height));
    }
}

let memoized_worker;

onmessage = (event) => {
    if ("init" in event.data) {
        memoized_worker = new QrCodeWebWorker(event.data["init"]);
    } else if ("data" in event.data) {
        memoized_worker.render(event.data.data, event.data.width, event.data.height).then(output => {
            postMessage({source: event.data.data, result: output});
        });
    }
};

This JavaScript file contains of two parts, the class QrCodeWebWorker with a constructor and the render method. Second an event handler function assigned to the onmessage callback that is available in the global context. The onmessage event function handles two types of events, differentiated by the key init & data. As we have seen earlier the QrCodeRenderer class sends an init event to the worker receives. The worker then creates an instance of the QrCodeWebWorker class (stored in variable memoized_worker).

The interesting part here is the constructor of the QrCodeWebWorker, it calls the function importScripts to load the content of the external JS file dist/rust_wasm_example.js which was generated by the wasm-bindgen CLI tool.

As described above, a web worker has a separate life cycle from the main thread, they communicate by sending messages via the postMessage function. The following flow describes how a QR code is rendered.

  • the function QrCodeRenderer.render is called to encode a character string into a SVG string that represents the QR code
  • this function registers a new event listener (qr_code_rendered) associated with the given character string, and sends the string in a message via postMessage to the worker
  • the web worker receives this message (with data key) in its onmessage event handler, calls the QrCodeWebWorker.render function which encapsulates the computation in a Promise
  • once this Promise is resolved the result is sent to the JavaScript main thread via postMessage
  • the QrCodeRenderer.handleMessage in the main thread receives the event & dispatches the result (the SVG string) to the registered event handler called qr_code_rendered

The purpose of using the extra event handler qr_code_rendered is to ensure the result can be resovled inside the Promise of the QrCodeRenderer.render function. This seems a bit complicated, but embedded into a web site, all that has to be called is the QrCodeRenderer.render function to return a Promise.

Ok, lets adjust the index.html file to call the QrCodeRenderer.render function to render the same QR code via web worker.

<html>
  <head>
    <title>QRCode WASM</title>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
    <script src="QrCodeRenderer.js"></script>
    <script>
      const wasm_uri = `dist/qr_wasm_bg.wasm`;
      const worker_uri = `QrCodeWebWorker.js`;
      const renderer = new QrCodeRenderer(worker_uri, wasm_uri);

      window.addEventListener(`load`, function(event) {
        let promise = renderer.render("ABCD1234", 256, 256);
        promise.then(svg => {
          document.body.insertAdjacentHTML(`beforeend`, svg);
        });
      });
    </script>
  </head>
  <body></body>
</html>

The HTML page now loads the QrCodeRenderer.js file instead of dist/qr_wasm.js. It also defines two URIs, one for the WASM module, the other for the QrCodeWebWorker.js file. These are given as parameters to the constructor of the QrCodeRenderer class. The remaining script is basically the same as above. Run the command http-server -p 8080 again in the root folder, go to the URL http://localhost:8080.

Open the Developer Tools in Chrome or Firefox to see the web worker QrCodeWebWorker is available.

Findings

The QR code generation via WebAssembly was mostly a refactoring of an existing feature. During the planning & implementation of the feature we noticed a few things. Before the change the QR codes were generated in a different process & stored in a database table as PNG images. These images were transferred as Base64 encoded strings to the front end amounting for about 50% of the total payload.

Due to the way how things were stored before, data was fetched from two different sources. Once the refactoring was is place, one API call could be avoided, all the data was fetched from a single location. By shifting the computation of the QR codes from a worker process on the server side to the front end, not only the payload was smaller, but the QR codes were now treated in a transient manner. There was no need anymore to store them separately in the database, completely avoiding out of sync situations. In the previous the context in which all information required to create the QR code was remote from where it was actually used, in a different application altogether. The refactoring also opened up further refinements of the current architecture.

In total we were quite happy that we were able to refactor an existing feature with a version that was more robust, faster and avoided out of sync issues. Modern browsers already support web workers for a few years now, WebAssembly support for nearly one year, making this approach a feasible & productive alternative.

For readers interested in the different approaches we tried, please check out the repository on Github.

References

Readers that want to learn more about Rust & WebAssembly the following links are a good start:

The following links provide other first hand experiences with Rust & WebAssembly

To learn more about web workers