DEV Community

Mehdi Vasigh
Mehdi Vasigh

Posted on

Getting Started with JavaScript Web Workers and Off-Main-Thread Tasks

JavaScript in the browser is single-threaded by design, meaning that all of our JavaScript code will share the same call stack. At first glance, this seems a bit implausible; we perform concurrent operations all the time using Promises. However, this concurrency (along with setTimeout, setInterval and others) is achieved using the event loop.

Usually, this is more than enough, especially for apps that mostly fetch data and display it, or accept input and persist it using HTTP and a server. However, as client-side apps continue to become more complex and "app-like" we tend to run an increasing amount of JavaScript in the browser, which places stress on our one single thread (or the "main thread"). Fortunately, we have Web Workers to help us relieve the main thread by running JavaScript code in background threads!

What is a Web Worker?

Per MDN, Web Workers are a simple means for web content to run scripts in background threads. They are not to be confused with Service Workers, which are concerned with proxying your application's network requests. The value of Web Workers is that they enable parallelism, giving your application the ability to run multiple JavaScript execution contexts at the same time.

There are a couple of important limitations to consider when using Web Workers:

  1. Web Workers execute in a completely separate JavaScript environment and don't share memory with your main thread, instead communicating with messages
  2. Workers have a different global scope than the main JS thread: there is no window object, and thus there is no DOM, no localStorage and so on
  3. The actual JS code for your worker has to live in a separate file (more on this later)

Although they are used somewhat infrequently, Web Workers have been around for a long time and are supported in every major browser, even going back to IE 10 (source)

Web Workers API browser support chart

Quick note on concurrency vs. parallelism

Although concurrency and parallelism seem at first to be two terms referring to the same concept, they are different. In short, concurrency is making progress on multiple tasks without doing them in order; think of your application invoking fetch multiple times and performing some task after each Promise resolves, but not necessarily in order and without blocking the rest of your code. Parallelism, however, is doing multiple things at the same time, on multiple CPUs or multiple CPU cores. For more on the topic, check out this awesome StackOverflow post that has several different explanations!

Basic example

Alright, enough exposition, let's look at some code! To create a new Worker instance, you must use the constructor, like so:

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

As mentioned above, this path does have to actually point to a separate JavaScript file from your main bundle. As such, you may have to configure your bundler or build chain to handle Web Workers. If you are using Parcel, Web Workers are handled out of the box! Therefore, we'll use Parcel for the rest of this post. Using Parcel, you can construct a Worker instance by passing a relative path to the actual source code for your worker instead, like so:

// main.js
const worker = new Worker('./worker.js');

NOTE: If you are using Parcel within CodeSandbox, this particular feature isn't currently supported. Instead, you can clone a Parcel boilerplate like this one or make your own, and experiment locally.

This is great, because now we can use NPM modules and fancy ESNext features in our Worker code, and Parcel will handle the task of spitting out separate bundles for us! 🎉

Except, worker.js doesn't exist yet... let's create it. Here's the minimal boilerplate for our Web Worker:

// worker.js
function handleMessage(event) {
  self.postMessage(`Hello, ${event.data}!`);
}

self.addEventListener('message', handleMessage);

Notice that we use self here rather than window. Now, let's go back to our main script and test out our Worker by posting a message to it and handling the response:

// main.js
const worker = new Worker('./worker.js');

function handleMessage(event) {
  console.log(event.data);
}

worker.addEventListener('message', handleMessage);

worker.postMessage('Mehdi');
// Hello, Mehdi!

That should do the trick! This is the minimal setup for working with a Web Worker. A "hello world" app is not exactly CPU-intensive however... let's look at a slightly more tangible example of when Web Workers can be useful.

Bouncy ball example

For the sake of illustrating the usefulness of Web Workers, let's use a recursive Fibonacci sequence calculator that performs its job super inefficiently, something like this:

// fib.js
function fib(position) {
  if (position === 0) return 0;
  if (position === 1) return 1;
  return fib(position - 1) + fib(position - 2);
}

export default fib;

In the middle of our calculator, we want to have a bouncy ball, like so:

Bouncy ball beside Fibonacci calculator form

The bounce animation is happening in a requestAnimationFrame loop, meaning that the browser will try to paint the ball once every ~16ms. If our main-thread JavaScript takes any longer than that to execute, we will experience dropped frames and visual jank. In a real-world application full of interactions and animation, this can be very noticeable! Let's try to calculate the Fibonacci number at position 40 and see what happens:

Bouncy ball freezing while calculating

Our animation freezes for at least 1.2 seconds while our code is running! It's no wonder why, as the recursive fib function is invoked a total of 331160281 times without the call stack being cleared. It's also important to mention that this depends entirely on the user's CPU. This test was performed on a 2017 MacBook Pro. With CPU throttling set to 6x, the time spikes to over 12 seconds.

Let's take care of it with a Web Worker. However, instead of juggling postMessage calls and event listeners in our application code, let's implement a nicer Promise-based interface around our Web Worker.

First, let's create our worker, which we will call fib.worker.js:

// fib.worker.js
import fib from './fib';

function handleMessage(event) {
  const result = fib(event);
  self.postMessage(result);
};

self.addEventListener('message', handleMessage);

This is just like our previous Worker example, except for the addition of a call to our fib function. Now, let's create an asyncFib function that will eventually accept a position parameter and return a Promise that will resolve to the Fibonacci number at that position.

// asyncFib.js
function asyncFib(pos) {
  // We want a function that returns a Promise that resolves to the answer
  return new Promise((resolve, reject) => {
    // Instantiate the worker
    const worker = new Worker('./fib.worker.js');

    // ... do the work and eventually resolve
  })
}

export default asyncFib;

We know that we will need to handle messages from our worker to get the return value of our fib function, so let's create a message event handler that captures the message and resolves our Promise with the data that it contains. We will also invoke worker.terminate() inside of our handler, which will destroy the Worker instance to prevent memory leaks:

// asyncFib.js
function asyncFib(pos) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./fib.worker.js');

    // Create our message event handler
    function handleMessage(e) {
      worker.terminate();
      resolve(e.data);
    }

    // Mount message event handler
    worker.addEventListener('message', handleMessage);
  })
}

Let's also handle the error event. In the case that the Worker encounters an error, we want to reject our Promise with the error event. Because this is another exit scenario for our task, we also want to invoke worker.terminate() here:

// asyncFib.js
function asyncFib(pos) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./fib.worker.js');

    function handleMessage(e) {
      worker.terminate();
      resolve(e.data);
    }

    // Create our error event handler
    function handleError(err) {
      worker.terminate();
      reject(err);
    }

    worker.addEventListener('message', handleMessage);
    // Mount our error event listener
    worker.addEventListener('error', handleError);
  })
}

Finally, let's call postMessage with the pos parameter's value to kick everything off!

// asyncFib.js
function asyncFib(pos) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./fib.worker.js');

    function handleMessage(e) {
      worker.terminate();
      resolve(e.data);
    }

    function handleError(err) {
      worker.terminate();
      reject(err);
    }

    worker.addEventListener('message', handleMessage);
    worker.addEventListener('error', handleError);

    // Post the message to the worker
    worker.postMessage(pos);
  })
}

And that should do it. One last thing left to do: check to make sure it works. Let's see what our app looks like when calculating the Fibonacci number at position 40 with our new asyncFib function:

Bouncy ball continuing to bounce while calculating

Much better! We've managed to unblock our main thread and keep our ball bouncing, while still creating a nice interface for working with our asyncFib function.

If you are curious, play around with the example app or check out the code on GitHub.

Wrapping up

The Web Worker API is a powerful and underutilized tool that could be a big part of front-end development moving forward. Many lower-end mobile devices that make up a huge percentage of web users today have slower CPUs but multiple cores that would benefit from an off-main-thread architecture. I like to share content and write/speak about Web Workers, so follow me on Twitter if you're interested.

Here are also some other helpful resources to get your creative juices flowing:

Thanks for reading!

Discussion (6)

Collapse
abhinav1217 profile image
Abhinav Kulshreshtha

Amazing tutorial. I love the step by step approch. This cleared some of my doubts I had before.

Do you have something similar on service worker? I actually used to think service worker and web worker were same.

Collapse
mvasigh profile image
Mehdi Vasigh Author

Thank you! I don't have anything written up on Service Workers yet, but I'll add it to my topic list as that'd be a great topic.

I think they're easy to confuse due to naming, I did the same. If you want to learn SWs check out Workbox. It's a set of libraries for building Service Workers. I found this tutorial which looks helpful: smashingmagazine.com/2019/06/pwa-w...

Collapse
abhinav1217 profile image
Abhinav Kulshreshtha

I would read it when you write an article on it. Thanks for the smashing magazine article. Even though I receive their newsletters, I don't remember seeing this article.

Collapse
beebeewijaya profile image
Bee Bee Wijaya

Great, it's really a good tool, especially when we want to process a long queue system

Collapse
nikla profile image
Nikla

Great article, very to the point and excellent example with the animation.

Collapse
mvasigh profile image
Mehdi Vasigh Author

Thanks, glad you enjoyed it!