Parallelization in JavaScript with Web Workers

Most of the time, JavaScript developers don't need to worry about CPU-intensive tasks. Every once in a while, though, some long-running, CPU-heavy work needs to be done client-side (for example, plotting an equation on a graph). If you try to do that sort of work in plain-old JavaScript client-side, it will block and cause the website to freeze up and become unresponsive until it's done, which isn't very good UX.

Although JavaScript as a programming language does not support multithreading, it is possible to parallelize client-side work (with some limitations) using the web worker API, which is useful for keeping your site responsive when you need to do some blocking work client-side. Web workers also make it possible to do some really cool stuff that otherwise wouldn't be practical to do in JavaScript at all (e.g. rendering fractals).

The basics

A web worker is a script that runs on a thread separate from a website’s main JavaScript thread (which I'll call the "UI thread" in this post). They consist of an onmessage function declared in the global scope and any other functions & data you define in the web worker script.

onmessage = function (message) {
  // payload of message from UI thread is in message.data
  // ... do some work here ...  
};

The "message" parameter of the onmessage function is the message that was passed to the web worker by the UI thread. We'll get into that shortly - first, we have to load the web worker into our app.

There are a lot of different ways to load web workers, which we'll look at in more detail later. The simplest way is to use the Worker constructor and pass it the URL to your worker script:

var worker = new Worker('path/to/worker.js');

Loading a web worker does not execute the code in the onmessage function. To start the web worker, you call postMessage on the worker object with the data you want to pass it, like this:

worker.postMessage({
  // ... message properties ...
});

Technically, that's all you need to do to use web workers. The code in the onmessage function will execute on another thread as soon as you call postMessage from your UI thread. Most of the time, though, we'll probably want the worker to return some data to the UI thread (e.g. the result of a calculation). To do that, you call postMessage in the worker with the data:

onmessage = function (message) {
  // ... do some work here ...

  postMessage({
    //  ... message properties ...
  });
};

For your UI thread to be able to receive the data, you need to set the onmessage callback on the worker object:

worker.onmessage = function (message) {
  // message payload is in message.data
  // ... handle message here ...
};

The onmessage callback will be invoked in your UI thread when the worker calls postMessage.

The code in a web worker script will run on a separate thread entirely and will not block the UI thread. You could have an infinite loop running in your web worker and callbacks will still fire on your UI thread. This makes them very useful for long-running, blocking work that would normally cause your app to freeze up and become unresponsive.

The web worker will execute until it reaches the end of the onmessage function, or until your UI thread tells it to terminate. To terminate a web worker from the UI thread, call the terminate function on the worker object:

worker.terminate();

Well - that covers the basics! Web workers are pretty easy to understand at a high level, especially if you have experience writing multithreaded applications. Just like everything else, though, the devil is in the details. There are a few things that are important to understand before you use web workers in your next project.

Web Worker Programming Model

Web workers are entirely separate from the UI thread and each other, and do not share any memory. This means that each worker has its own global object (a WorkerGlobalScope object instead of a window object) and, most importantly, that your UI thread and your workers cannot share object references. As you may have guessed, this means that messages passed to workers are deep copied and not passed by reference, so you can't pass an object to a worker and then expect it to mutate the object's state. The only way to get the result of work done in a web worker is to pass it in a message back to the UI thread by calling postMessage in the worker.

Loading web workers

The simplest way to load a web worker into your script is to use the Worker constructor, passing it the URL to the worker script (also shown above):

var worker = new Worker('path/to/worker.js');

You can also load a web worker with a string and an object URL, as shown here:

var js = "onmessage=function(e){postMessage('Worker: '+e.data);}";

var blob;
try {
  blob = new Blob([js], {type: 'application/javascript'});
} catch (e) { // Backwards-compatibility
  window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder;
  blob = new BlobBuilder();
  blob.append(js);
  blob = blob.getBlob();
}

var worker = new Worker(URL.createObjectURL(blob));

Personally, I prefer to load web workers using a Webpack loader since it bundles the worker script in your main JavaScript bundle and then handles all the messy details above for you.

A (sort of) practical example

I have a fascination with fractals generated from the Mandelbrot set, and I recently put together a little JavaScript app that renders a Buddhabrot on an HTML5 canvas. It's computationally intensive to generate each set of points to plot on the canvas because there's a lot of math involved, so I ended up parallelizing the computations using web workers and I was very happy with the result.

I'm not going to get into the details of that project here though, since this post is about web workers and not math! Instead of that, here's a streamlined example that illustrates how I used web workers in that project.

Say you have a canvas, and you want to plot pixels of randomly-chosen colors at random points on it. And you want to do it fast.

To generate your pixels, you need to generate random numbers for the R, G, B, and A values, and a random set of coordinates on the canvas. That means that your UI thread will need to give each worker the dimensions of the canvas, and it will expect to receive randomly-generated pixels back via the onmessage callback.

/* UI thread code */

var pixelsToDraw = [],
  workers = [];

var createWorker = function (canvasWidth, canvasHeight) {
  var worker = new Worker('path/to/worker.js');

  worker.onmessage = function (message) {
    for (var i = 0; i < message.data.length; i++) pixelsToDraw.push(message.data[i]);
  };

  worker.postMessage({
    canvasWidth: canvasWidth,
    canvasHeight: canvasHeight
  });

  return worker;
};

var startWorkers = function (options) {
  var canvasWidth = options.canvasWidth,
    canvasHeight = options.canvasHeight,
    numberOfWorkers = options.numberOfWorkers;

  for (var i = 0; i < numberOfWorkers; i++) workers.push(createWorker(canvasWidth, canvasHeight));
};

var terminateWorkers = function () {
  for (var i = 0; i < workers.length; i++) workers.pop().terminate();
};


/* entry points */

document.getElementById('startButton').addEventListener('click', startWorkers);

document.getElementById('stopButton').addEventListener('click', terminateWorkers);

/* Worker code */

var getRandomWholeNumberLessThan = function (max) {
  return Math.floor(Math.random() * max);
};

onmessage = function (message) {
  var canvasWidth = message.data.canvasWidth,
    canvasHeight = message.data.canvasHeight;

  // keep generating new pixels until the worker is terminated
  while (true) {
    var x = getRandomWholeNumberLessThan(canvasWidth),
      y = getRandomWholeNumberLessThan(canvasHeight),
      r = getRandomWholeNumberLessThan(256),
      g = getRandomWholeNumberLessThan(256),
      b = getRandomWholeNumberLessThan(256),
      a = getRandomWholeNumberLessThan(256);
  
    postMessage({
      x: x,
      y: y,
      r: r,
      g: g,
      b: b,
      a: a
    });
  }
};

Note that the UI thread creates multiple workers. No browser sets a hard limit on the number of workers you can spin up, but you will of course be limited by the machine the browser is running on.

Whenever a worker posts a message, the onmessage callback gets fired in the UI thread and the data yielded by the worker gets pushed onto an array. You might have a recursive draw routine that consumes the data in this array and draws it on the canvas:

var drawPixelOnCanvas = function (pixel) {
  /* ... set r, g, b, and a values of pixel at pixel.x, pixel.y on the canvas ... */  
};

var draw = function () {
  (function drawInternal () {
    var pixel;
    while ((pixel = pixelsToDraw.pop())) drawPixelOnCanvas(pixel);

    setTimeout(drawInternal, 0);  // use setTimeout to allow worker onmessage callbacks to run
  })(); 
};