Easier Web Workers

Ever been on a web page and everything feels a bit slow? Delays typing, scrolling, and general interactions with the page? One of the main causes of this is 'blocking the main thread'. Browsers do their best to keep the rendered contents of a page in sync with the refresh rate of a monitor (generally this is about 60 frames per second). However doing expensive operations in your main thread (i.e. where your everyday JavaScript is executed) has the potential to block it, preventing efficient page rendering and in turn delaying response to user interactivity such as scrolling, inputs, etc.

Thankfully due to the power of Web Workers, we can offload heavy computations to another thread, leaving the main thread for handling rendering and user interactions. Web Worker's run a JavaScript file as a background thread that that runs as a separate context to the browsers main thread. So how do we construct a worker? Like so:

const worker = new Worker("worker.js");

Here worker.js is the code that will listen for the message from the workers and perform the specified work.

Workers are pretty flexible, but one core thing you can't do is access and manipulate the DOM. They also require you to pass data to them and the data is not shared, unless you're using Transferables. You can natively pass any data that is allowed in the Structured Clone Algorithm to a worker. In practice this means most things minus Functions, Errors and DOM Elements. Here JSON.stringify may bring some performance benefits, although that's worth testing for your use case first. It is worth mentioning that JSON.stringify also has various types that do not convert including functions, Date objects, regular expressions and undefined.

Since data is not shared, there is a performance overhead copying data to the worker. The exception here is previosuly mentioned Transferables which are 'zero-copy' meaning data is transferred to the thread context instead. This can be an order of magnitude faster than copying.

There is a cost to instantiating a Web Worker which will vary from browser and device, but this Mozilla article articulates that you're looking around the 40ms mark. Communicating over to a Web Worker (postMessage) is fast however, around 0.5ms of latency.

Passing Messages #

So what does a the code look like for passing data (a message) to and from a Web Worker look like?

// In our main JavaScript file

// Post data
worker.postMessage("Hello from the main thread!");

// Receive data
worker.addEventListener(
"message",
(event) => {
console.log("Data from worker received: ", event.data);
},
false
);

And then in the Web Worker (say webworker.js) we need a way to receive the message:

self.addEventListener(
"message",
(event) => {
console.log("Worker data received from the main thread", event.data);
// Do what we want with do something with event.data
self.postMessage(
`Hello from the Web Worker thread!
The message received had length:
${event.data.length}`

);
},
false
);

Here we can see that once the message is received we can manipulate the incoming data as we see fit and send it back with 'postMessage`.

A simple Web Worker example #

To give a more tangible example, I have created an example repository which shows how we can produce large numbers of primes in a Worker whilst maintaining interactivity with the page.

Are there any nice abstraction libraries? #

Yes! I have compiled a list of Hello World examples using various popular libraries. Namely:

You can see all of those examples in my GitHub repo here. There are others that might be worth checking out depending on your use case that I haven't added.

Let's take a little look at how Greenlet might work. Using ES7 async/await syntax, we get readable code, without sacrificing on functionality. Under the hood greenlet does something pretty cool, it generates an inline Web Worker using URL.createObjectURL and Blob. This allows us to do like so:

const asyncSieveOfEratosthenes = greenlet(async (limit) => {
// Code redacted for brevity
});

const calculate = document.getElementById("calculatePrimes");
const message = document.getElementById("showPrimes");

calculate.addEventListener("click", async () => {
const n = 100000000;
message.innerHTML = "Main thread not blocked!";
// The following async function won't block:
const totalPrimes = await asyncSieveOfEratosthenes(n);
calculate.innerText = "Done!";
message.innerHTML = `${totalPrimes.length} prime numbers calculated!`;
});

Pretty cool if you ask me!

What about support? #

Web Workers are very well supported by all major browsers, so this shouldn't be an issue:

Web Worker Support

When to use Web Workers? #

Some people may be tempted to try and start moving all there app logic over to a Web Worker. There is no guarantee that this will be any more performant. Web Workers make the most sense when you have heavy processing that would block the main thread and rendering and user interaction. For example, imagine you want to do some intensive number crunching, geometry processing (see for example Turf.js) or deep tree traversal and manipulation. The most useful piece of advice I can give here is profile and benchmark it. If you're new to profiling, check out this piece on CPU profiling in Chrome.

Fibrelite #

I am currently working on a library called Fibrelite which is based off of Jason Millers fantastic greenlet library. The aim is to produce a general purpose library for spinning out async functions as Web Workers, but with a variety of approaches to handling those function calls, for example pooling, prioritising calls or debouncing calls where necessary. This would be beneficial for any situation where both user interactions and intensive calculations are in tandem. I will write a more detailed blog post at a later date, in the mean time, check out a demo here.

Published