Close menu

Multithreaded JavaScript
(Web Workers)

More work; less waiting.

While it's true that JavaScript started as a single threaded language, it's still often not realised that this changed... in 2012.

With stable support in all major browsers, and even IE from version 10, multithreaded JavaScript is an important and exceptionally powerful feature, particularly if your concern is, like ours here at Beyond The Sketch, producing high performance websites and webapps. Unfortunately, it's not something we see used as often as it could/should be.

Do More Work

Web Workers, or just Workers, allow you to execute a specified script in a thread, separate from the main thread. You can have multiple workers 'spawned' by your main JavaScript, and workers themselves can create more workers - and these are actually separate CPU threads, so they run completely in parallel.

Workers can't be used to do everything however - the most important of which is they can't access or manipulate the DOM (i.e. document). They can however do most other things, including XHR, using browser storage APIs, Math, Date, timers etc.

If you haven't figured it out yet, the intention of Workers is to allow you to perform computationally expensive operations in parallel to the code that is handling the DOM - i.e your UI.

When to use a Worker

Doing some big math? Iterating over massive arrays? Moving a lot of data to and from storage? Requesting, transforming, filtering, mapping or otherwise manipulating big JSON responses? All pretty good examples of when to use a Worker.

One example to illustrate the use case for Workers could be filtering or sorting results, such as search results. Quite often, the pattern you'll find used is to make a network request for every query, including for sorting or filtering an existing list so that the data is processed server side, or else re-rendered client side - but if your result set initially contains all the results for the requested search criteria, using a worker to apply filters or sorting to that set can result in a huge improvement to the perception of speed as well as reducing server load, and network requests, which is an expensive operation.

When not to use a Worker

Basically everything mentioned in when to use a Worker but replace big, massive & lot of with small, tiny and little.

However, that's not to say you absolutely shouldn't use them in these cases - sometimes a worker can provide benefits for these smaller types of operations. More on this later.

Other Types

This article looks at dedicated workers; which is a worker used by a main script on a single domain. But there are other types of workers…

Shared workers create workers that can be used by scripts running across different domains and windows etc, communicating via a port. Service workers are used by PWAs, to enable offline mode, amongst other things.

However, those are outside the scope of this article; we're only looking at dedicated workers for this one.

How To

Workers are wonderfully simple to use! Unlike some other languages where thread management can be quite complex, creating, using and destroying threads in JS is exceptionally easy.

Conceptually, they operate by passing messages to and from the script that spawned it. A message can consist of any value or JavaScript object that the structured clone algorithm handles.

In the main thread script, which we'll refer to as the main script, a worker is spawned using the Worker constructor, taking a string argument which is the path to the worker script; this script will execute on a new, separate thread.

The Worker instance that is returned in the main script, has a postMessage method, which is used to send a message to the worker script.

It also receives a message event, which is how you will receive messages back from the worker script.

On the worker script, you have the same configuration in it's global scope; a postMessage function, which you use to send messages back to the main script, alongside receiving a message event, which you use to receive messages from the main script.

Messages are sent (and received) asynchronously between main and worker scripts.

postMessage on both the main script instance and the worker context take 2 arguments. The first is the data to post, which is copied, not passed by reference, and the second optional argument takes an array of Transferable objects to transfer ownership of. When any data is transferred via the second argument, it becomes unavailable to the script that posted it.

Let's look at an example where our main script spawns a worker to fetch and sort a list from a JSON response, then send that list back to the main thread to be displayed in the UI:

First, we'll setup the worker in the main script and post a message to it. We want this message to tell the worker to get some data and sort it, so we'll keep the data we send simple, and just send a string of 'request':

/* 📄 main.js */
 
const sortWorker = new Worker('fetchandsort.js');

// send data to the worker
sortWorker.postMessage('request');

Now over in the worker file, we need to listen for the message event in order to get the message posted by the main script. Remember, this is done in the worker's context, which is the global scope:

/* 📄 fetchandsort.js */
 
// listen for messages from main script
self.addEventListener('message', (data) => {
      // do something with the data
});

Notice how we've used self instead of window, it's a good idea to do this because self points to the global context and will work across different environments such as the browser and node.

When the worker receives the string 'request', we want it to go and fetch some data, and sort it somehow; let's say alphabetically.

/* 📄 fetchandsort.js */

// '/some/api' returns array ['foo', 'widgets', 'bar']
 
// listen for messages from main script
self.addEventListener('message', (data) => {
  if (data === 'request') {
      fetch('/some/api').then(
          (results) => {
              results.sort((a, b) => {
                  if (a > b) return 1;
                  if (a < b) return -1;
                  return 0;
              });
          }
      );
  }
});

After the worker has received the message, made the network request and sorted the result, it has to send that data back to the main thread.

That's done via the worker's global postMessage function:

/* 📄 fetchandsort.js */

self.addEventListener('message', (data) => {
  if (data === 'request') {
      fetch('/some/api').then(
          (results) => {
              const sortedResults = results.sort(
                  (a, b) => {
                      if (a > b) return 1;
                      if (a < b) return -1;
                      return 0;
                  }
              );

              self.postMessage(sortedResults);
          }
      );
  }
});

The posted message is then received on the main script's worker instance; to process the message, you need to listen for the message event, just like we do in the worker itself:

/* 📄 main.js */
 
const sortWorker = new Worker('fetchandsort.js');

// send data to the worker
sortWorker.postMessage('request');
 
sortWorker.addEventListener('message', (data) => {
  console.log(data); // ['bar', 'foo', 'widgets']
});

Workers live as long as the main thread does. So closing the browser window of the main script will also stop any workers it spawned.

Sometimes however, you may need to stop a worker immediately. This can be done using the terminate method of the Worker instance. This method stops the worker in it's tracks and doesn't let it finish whatever it might have been doing at the point it was called.

/* 📄 main.js */
 
sortWorker.terminate();

Balance

As with most things, Workers themselves have a performance cost, so it’s important to balance out the cost of spawning workers with the performance gain you will see in your site or app. Data moving from the main thread to a worker is copied, not passed by reference, so you can expect costs such as increased memory usage etc. But really these increases are minuscule and often negligible in the grand scheme of things.

While we’ve suggested using Workers only when dealing with larger amounts of data, sometimes, the benefits of running a separate thread might be worth the overhead that comes with creating workers in the first place. Large amounts of main thread work is a leading cause of performance degradation in website/webapp code, so separate threads doing small work may still have a net benefit in the perception of speed of your website. Your mileage may vary, and the best thing to do is test and audit your particular use case.

They may not be the holy grail solution to building high performance websites and apps, but workers certainly contribute enormously to building sites and webapps that remain fast and responsive as they ensure the UI thread (I.e the main thread) is less likely to get locked from some long running synchronous operation like iterating over large arrays, which just happens to be commonplace in a lot of code that process JSON responses.

First published: 25/02/2021