Blog Post

How to Use WebAssembly Modules in a Web Worker

Illustration: How to Use WebAssembly Modules in a Web Worker
Information

This article was first published in June 2020 and was updated in September 2024.

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, you’ll create a small example to learn how to use WebAssembly modules inside web workers. You’ll 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, you’ll also learn about communicating data across the main and worker threads in a convenient way.

Prerequisites

You’ll need Emscripten to convert your C++ code to WebAssembly, which can then be used on the web. You can do this by running the following commands:

# 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.

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

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, you can take a look at the final project structure, which will help you locate the correct place to put a particular file:

┣ wasm/
┃ ┣ add.cpp
┃ ┣ add.js
┃ ┗ add.wasm
┣ index.html
┣ index.js
┣ package.json
┣ wasm.worker.js
┣ webpack.config.js

Start with index.html, which is the main HTML file for your project. It’ll include the bundle.js file generated by webpack, which contains the bundled JavaScript code for your application. The body section is left empty, as your JavaScript code will dynamically handle the page content and interactions:

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta
			name="viewport"
			content="width=device-width, initial-scale=1.0"
		/>
		<meta http-equiv="X-UA-Compatible" content="ie=edge" />
		<title>Document</title>
	</head>
	<body></body>
	<script src="./dist/bundle.js"></script>
</html>

C++

Once you’ve installed Emscripten, you can write a function that adds two numbers in C++:

// 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 you can convert the code above to a WebAssembly module that can be consumed on the web. Before running the conversion command, make sure you navigate to the wasm directory:

cd wasm

Then, use Emscripten to compile the C++ code to WebAssembly with the following command:

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 you can import directly on the web.

Webpack Configuration

You’ll need the following loaders to make sure you’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 you’ll have to disable webpack’s default handling of wasm files and then fetch the wasm file by using the file path you get using file-loader.

  • worker-loader — This loader allows you to import the worker file directly in your main file without worrying about its location. This loader also provides compatibility if the browser doesn’t support web workers.

The final webpack configuration will look like this:

// webpack.config.js

module.exports = {
	mode: 'development', // Set to "development", "production", or "none".
	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: {
						filename: '[name].worker.js', // Customize the filename for the worker if needed.
						publicPath: 'dist/', // Specify the public path if necessary.
						esModule: false, // Set esModule to `false` for compatibility.
					},
				},
			},
			{
				test: /\.wasm$/,
				type: 'javascript/auto', // This disables webpack's default handling of wasm.
				use: [
					{
						loader: 'file-loader',
						options: {
							name: 'wasm/[name].[hash].[ext]',
							publicPath: '/dist/',
						},
					},
				],
			},
		],
	},
};

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

// `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 = await addJS({
			wasmBinary: buffer,
		});

		resolve(_instance._add(a, b));
	});

expose(sum);

In index.js, you’ll import the worker file, which can be executed as a script, thanks to worker-loader. Then you’ll use wrap from Comlink to get a function that will directly call the sum function you 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:

// `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, add a script in package.json to generate a bundle:

"scripts": {
      "build": "webpack"
   },

You 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.

Run npx serve from the root directory to serve the HTML on http://localhost:3000/. As soon as you open the URL on the browser, you’ll 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, you created a small example to demonstrate how you 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.

Author
Ritesh Kumar Web Engineer

Ritesh loves to write code, play keyboard, and paint. He likes working on projects that involve developer tooling, design systems, and music. He wants to make art a part of everyone’s life by using technology.

Share post
Free trial Ready to get started?
Free trial