Web Workers Demystified

A web worker is a simple way to run Javascript code in background threads on the browser.

The code in the workers will not block the UI and you can run intensive operations on it, without losing those precious 60fps.
Examples for such operations that websites tried using in recent years are mining cryptocurrency or distributed computing on browsers (it might not the best idea, but it is completely possible 🤷‍♂️).
You get it, we can do all kind of wacky things when we get extra computing power without compromising the UI, there are a lot of Machine Learning and Gaming implications too, you can find out more in this stack overflow question.

A factory worker by pexels

You can find the git repo for the examples here.

I’m using http-server to run and develop these examples, to make it simple for you I added it as a dependency and there are scripts to run the examples which are full of comments. When running through npm the port will be 8080.

# install dependencies
npm install
# run the example
npm run example_1
##### outputs ######
Starting up http-server, serving 1_ping-pong
Available on:
http://127.0.0.1:8080
http://172.21.9.21:8080
Hit CTRL-C to stop the server

Dedicated Workers

Dedicated workers are simple, you give it a script and it runs it in a contained thread, it doesn’t interfere with the UI and the only communication with it is through messages (postMessage/onmessage), and any message is cloned, so we cannot hit deadlocks and race conditions (very unpleasant things you might find in concurrent languages like Go and Java).
They are very thread-safe and can be used easily for different things.

Checking if Worker is supported is easy:

if (window.Worker){ //…do stuff with worker}

Example 1: Playing Ping Pong

Let’s start with a simple example, we will create a web worker that accepts a message “PING” and returns a message “PONG”, the UI thread, in this case, is our completely regular javascript file (script.js) and our Worker thread is the script that runs the worker (worker.js).

Playing ping pong with web worker

Script.js

// make sure Worker is supported here
if
(window.Worker) {
    // this can be a relative path or a complete URL,
// it must be on the same origin
const
workerScriptPath = 'worker.js';
    // create a new worker from our script
let
worker = new Worker(workerScriptPath);
    // wait for a message and log it's data
worker.onmessage = (e) => console.log(e.data);
    // post a message to the worker
worker.postMessage('PING');
} else {
alert('No worker support here, weird, it should be supported in IE10 and above 🧐')
}

And for our worker script, we will not have access to the DOM or the window through it, but we will have global object of type DedicatedWorkerGlobalScope and we can use it to communicate back to our UI script, this is the code in worker.js.

onmessage = e => postMessage('PONG');

Example 2: Playing Ping Pong with subworkers

Each web worker can create his own sub worker, this allows us to divide work across multiple threads, or in this case chain threads (Never do it in production, it’s pretty useless).

Chaining ping pong with subworkers

The only thing we need to change is worker.js, to:

onmessage = e => {
postMessage('PONG');
// wait 1 second and spawn a sub worker and tell it to PING too,
// this creates an infinite loop using web worker recursion 😱
setTimeout(() => {
let worker = new Worker('worker.js');
worker.onmessage = (e) => console.log(e.data);
worker.postMessage('PING');
}, 1000)
};

Now we have an infinite loop of ping pong Workers 🤗. I’m spawning a lot of workers here and in real-world scenarios you can do so too, we will see a better more sustainable example at example 4 where we will delegate work to multiple workers.
* Too many workers can make the browser get out of memory and crash.

Example 3: Importing scripts for workers

Nowadays this is pretty useless since you would probably use webpack to pack multiple modules into a single script, but if you wish, you can import scripts directly into the worker using “importScripts”.
So, for example, you can see this worker, that will turn strings into kebab case.

importScripts('https://cdn.jsdelivr.net/npm/lodash@4.17.11/lodash.min.js');

onmessage = ({data}) => {
postMessage(_.kebabCase(data))
};

So, for the script:

// create a new worker from our script
let
worker = new Worker('worker.js');
// wait for a message and log it's data
worker.onmessage = (e) => console.log(e.data);
// post a message to the worker
worker.postMessage('In the grim darkness of the far future there is only war');

We will get the output: “in-the-grim-darkness-of-the-far-future-there-is-only-war” (It’s a warhammer reference, look it up 😁).

Example 4: Distributing load across workers

We are going to use a similar system to the example I made in my “node worker threads” post, we will generate a huge random array and distribute it between workers so each will have to sort a small part, making the sort much faster, and less UI blocking.

Delegating work to multiple workers

For this example, each worker will close itself after its done sorting its share:

// CPU consuming function (sorting a big array)
function
sortBigArray(bigArray) {
return bigArray.sort((a, b) => a - b);
}

onmessage = ({ data }) => {
// sort
const
sorted = sortBigArray(data);
    // send the sorted array back
postMessage(sorted);
    // we are done and can close the worker 🙌
close();
};

And for our UI script (script.js), I use “console.time” and “console.timeEnd”, these are chrome features to easily count time for code execution.
Also I’m using “navigator.hardwareConcurrency” to get the best number of worker threads to use (the number of logical processors available), it has great browser compatibility, but just in case I have a default value of 4, you can spawn more workers, but 100 workers on an 8 CPU computer will be slower than 8 workers.

generated 5000000 numbers: 303.572021484375ms
sorted 5000000 numbers with 8 workers: 3900.599853515625ms
sorted 5000000 numbers on ui: 8191.495849609375ms

That’s 47.6% improvement in execution time, plus we didn’t block the UI when doing it with web workers, do notice that the browser has to clone the data for each worker, so that takes some extra time, but it was still worth it.

Shared workers

Shared workers are a bit more complicated and their use cases are usually very specific. MDN describe it perfectly:

A shared worker is accessible by multiple scripts — even if they are being accessed by different windows, iframes or even workers.

So, for our example, we are going to do something simple, yet fun. We will have a single state, that will persist through multiple tabs/windows.
It’s a simple counter, each HTML file will check the counter, increment/decrement it and report it back to the worker, that way we can have multiple windows with the same data, and they stay in sync.

Sharing a state across multiple browser windows/tabs

Checking if SharedWorker is supported is easy:

if (window.SharedWorker){ //…do stuff with worker}

Example 4: synchronizing state across windows

SharedWorkers are simple, we will first create the worker script, It’s different than “regular” Worker, we first need to wait for a connection using the “onconnect” method, and then we get a port, the port is the connection that we have to the window:

// this object is a state that will be cloned into every window
let
crossWindowState = {
counter: 0
};
// wait for a connection
onconnect = (e) => {
// we take the caller port
const
port = e.ports[0];

// on message we expect a json that will tell the worker what to do
port.onmessage = ({data}) => {
const {action, state} = data;

if (action === 'getState') {
port.postMessage(crossWindowState);
} else if (action === 'setState') {
crossWindowState = state;
port.postMessage(crossWindowState);
}
};
};

Now we have a worker that can accept an action, the action will be either “setState” or “getState”. The “crossWindowState” exists outside the connection, so it will outlive all the windows, it will live until the browser is closed.

For our UI script, at it’s simplest form it could be like this:

// create a shared worker
const
sharedWorker = new SharedWorker('worker.js');
// start listening to the port
// called when the setter of port.onmessage is used
sharedWorker.port.start();
// with an eventListener
sharedWorker.port.removeEventListener('message', listener);
// OR
// with the onmessage method
sharedWorker.port.onmessage = listener;
// And to send data
sharedWorker.port.postMessage(data);

This is what the script part actually looks like, I added some helper functions to make things easier to work with, and there are two buttons in the HTML part.
To increment and decrement the counter value, after each change and when synchronizing we update the output. In a React/Vue/Angular/etc… you can just throw this into your state management system and it would reactively update the entire page.

Now if we will run it, and open multiple tabs, they should be synced!
As you can see, I only update the state when the window changes it’s visibility or regain focus, but the state is shared across all the windows.

  • I did have one issue with the SharedWorker, in chrome, it didn’t work without incognito mode, probably thanks to one of my extensions, but in firefox and in chrome with incognito, it works great.
    It seems I’m not the only one but I’m sure that Chrome will find a way to fix this in the future.

Wrapping up

Web workers are great for many use cases, they are used for ML, AI, Gaming and just crazy ideas (mining bitcoin in the browser is kinda crazy).
Web workers have great browser support, all the way to ie10, SharedWorker has a less than perfect support, but it’s catching up.

If you want to join us and work in the Netherlands (Clockwork supports relocations), check out the open positions at https://clockwork.homerun.co .