How to Use WebAssembly Modules in a Web Worker

WebAssembly is a binary format that helps developers achieve near-native performance inside the browser. However, even though the native-like performance is a big advantage of using WebAssembly, it has its share of issues.

Creating an instance of a WebAssembly module (wasm) can take several seconds. This is dependent upon its size, which in turn can have an effect on the load time. Meanwhile, when this module is moved to a web worker, the main thread is kept free because the process of fetching, compiling, and initializing happens on a separate thread.

In this article, we will create a small example to learn how to use WebAssembly modules inside web workers. We will write a function to add numbers in C++, convert the function to wasm using Emscripten, and then import the wasm file in a web worker. At the same time, we will also learn about communicating data across the main and worker threads in a convenient way.

Prerequisites

We will need Emscripten to convert our C++ code to WebAssembly, which can then be used on the web. We can do this by running the following commands:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Get the emsdk repo.
git clone https://github.com/emscripten-core/emsdk.git

# Enter the directory.
cd emsdk

# Download and install the latest SDK tools.
./emsdk install latest

# Make the "latest" SDK "active" for the current user (writes `~/.emscripten` file).
./emsdk activate latest

# Activate `PATH` and other environment variables in the current terminal.
source ./emsdk_env.sh

If you are using Windows, run emsdk instead of ./emsdk, and run emsdk_env.bat instead of source ./emsdk_env.sh.

We also have to install Comlink, webpack-cli, and few loaders:

1
2
npm i --save comlink
npm i --save-dev webpack webpack-cli file-loader worker-loader

Next, as this blog post from LogRocket explains, “Comlink turns this message-based API into something more developer-friendly by providing an RPC implementation: values from one thread can be used within the other thread (and vice versa) just like local values.”

Project Structure

Before starting, we can take a look at the final project structure, which will help us in locating the correct place to put a particular file:

1
2
3
4
5
6
7
8
9
┣ wasm/
┃ ┣ add.cpp
┃ ┣ add.js
┃ ┗ add.wasm
┣ index.html
┣ index.js
┣ package.json
┣ wasm.worker.js
┣ webpack.config.js

C++

Once we have installed Emscripten, we can write a function that adds two numbers in C++:

Copy
1
2
3
4
5
6
7
8
9
10
// wasm/add.cpp

#include <iostream>

// extern "C" makes sure that the compiler does not mangle the name.
extern "C" {
    int add(int a, int b) {
        return a + b;
    }
}

Now we can convert the above code to a WebAssembly module that can be consumed on the web. For this, we will use Emscripten and run the following code:

1
emcc add.cpp -s ENVIRONMENT=worker -s MODULARIZE=1 -s EXPORTED_FUNCTIONS="['_add']" -o add.js

This will generate two files, named add.wasm and add.js, in the same directory. These files are the ones we can import directly on the web.

Webpack Configuration

We will need the following loaders to make sure we’re able to load the wasm file in a worker file and then register that worker as a script:

  • file-loader: The default way in which webpack loads wasm files won’t work in a worker, so we will have to disable webpack’s default handling of wasm files and then fetch the wasm file by using the file path that we get using file-loader.
  • worker-loader: Using this, we will be able to import the worker file directly in our main file without worrying about the file’s location. This loader also provides a fallback in case the browser doesn’t support web workers. In such a case, the script gets executed on the main thread.

The final webpack configuration will look like this:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// webpack.config.js

module.exports = {
  entry: "./index.js",
  output: {
    filename: "bundle.js",
    publicPath: "dist/",
    globalObject: 'typeof self !== "object" ? self : this'
  },
  module: {
    rules: [
      {
        test: /\.worker\.js/,
        use: {
          loader: "worker-loader",
          options: { fallback: true }
        }
      },
      {
        test: /\.wasm$/,
        type:
          "javascript/auto" /** this disables webpacks default handling of wasm */,
        use: [
          {
            loader: "file-loader",
            options: {
              name: "wasm/[name].[hash].[ext]",
              publicPath: "/dist/"
            }
          }
        ]
      }
    ]
  }
};

After setting up webpack, we can now successfully import the wasm file in the web worker file. Then we will use expose from Comlink to expose the function so that it can be easily consumed by index.js:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// `wasm.worker.js`

import { expose } from "comlink";
import addWasm from "./wasm/add.wasm";
import addJS from "./wasm/add.js";

const sum = async (a, b) =>
  new Promise(async resolve => {
    const wasm = await fetch(addWasm);
    const buffer = await wasm.arrayBuffer();
    const _instance = addJS({
      wasmBinary: buffer,
      onRuntimeInitialized: () => {
        resolve(_instance._add(a, b));
      }
    });
  });

expose(sum);

In index.js, we will import the worker file, which can be executed as a script thanks to worker-loader. Then we use wrap from Comlink to get a function that will directly call the sum function we defined in the worker file. Since it has to wait until it receives a response from the worker, this function is always asynchronous. So the index.js file will look like this:

Copy
1
2
3
4
5
6
7
8
9
10
// `index.js`

import { wrap } from "comlink";
import WasmWorker from "./wasm.worker";
const wasmWorker = wrap(new WasmWorker());

(async function() {
  const result = await wasmWorker(1, 4);
  alert(result);
})();

Now we should add a script in package.json to generate a bundle:

1
2
3
4
5
{
  "scripts": {
    "build": "webpack"
  }
}

We can generate the bundle file by running npm run build in the terminal, and the generated dist/bundle.js can be imported in index.html. As soon as you load the HTML file on the browser, you will see an alert with the number five logged on it. This means the setup is working correctly. You can see the source code of this example on GitHub.

Conclusion

In this blog post, we created a small example to demonstrate how we can use WebAssembly modules inside a web worker. At PSPDFKit for Web, we use WebAssembly to provide a client-only standalone solution. This makes it easy for users to get up and running without worrying about complex backend infrastructure.

PSPDFKit for Web

PDF viewing, annotating, and collaboration for web apps.

Try Now