Use web workers to run JavaScript off the browser's main thread

An off-main-thread architecture can significantly improve your app's reliability and user experience.

In the past 20 years, the web has evolved dramatically from static documents with a few styles and images to complex, dynamic applications. However, one thing has remained largely unchanged: we have just one thread per browser tab (with some exceptions) to do the work of rendering our sites and running our JavaScript.

As a result, the main thread has become incredibly overworked. And as web apps grow in complexity, the main thread becomes a significant bottleneck for performance. To make matters worse, the amount of time it takes to run code on the main thread for a given user is almost completely unpredictable because device capabilities have a massive effect on performance. That unpredictability will only grow as users access the web from an increasingly diverse set of devices, from hyper-constrained feature phones to high-powered, high-refresh-rate flagship machines.

If we want sophisticated web apps to reliably meet performance guidelines like the Core Web Vitals—which is based on empirical data about human perception and psychology—we need ways to execute our code off the main thread (OMT).

Why web workers?

JavaScript is, by default, a single-threaded language that runs tasks on the main thread. However, web workers provide a sort of escape hatch from the main thread by allowing developers to spin up separate threads to handle work off of the main thread. While the scope of web workers is limited and doesn't offer direct access to the DOM, they can be hugely beneficial if there is considerable work that needs to be done that would otherwise overwhelm the main thread.

Where Core Web Vitals are concerned, running work off the main thread can be beneficial. In particular, offloading work from the main thread to web workers can reduce contention for the main thread, which can improve a page's Interaction to Next Paint (INP) responsiveness metric. When the main thread has less work to process, it can respond more quickly to user interactions.

Less main thread work—especially during startup—also carries a potential benefit for Largest Contentful Paint (LCP) by reducing long tasks. Rendering an LCP element requires main thread time—either for rendering text or images, which are frequent and common LCP elements—and by reducing main thread work overall, you can ensure that your page's LCP element is less likely to be blocked by expensive work that a web worker could handle instead.

Threading with web workers

Other platforms typically support parallel work by allowing you to give a thread a function, which runs in parallel with the rest of your program. You can access the same variables from both threads, and access to these shared resources can be synchronized with mutexes and semaphores to prevent race conditions.

In JavaScript, we can get roughly similar functionality from web workers, which have been around since 2007 and supported across all major browsers since 2012. Web workers run in parallel with the main thread, but unlike OS threading, they can't share variables.

To create a web worker, pass a file to the worker constructor, which starts running that file in a separate thread:

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

Communicate with the web worker by sending messages via the postMessage API. Pass the message value as a parameter in the postMessage call and then add a message event listener to the worker:

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

To send a message back to the main thread, use the same postMessage API in the web worker and set up an event listener on the main thread:

main.js

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

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

Admittedly, this approach is somewhat limited. Historically, web workers have mainly been used for moving a single piece of heavy work off the main thread. Trying to handle multiple operations with a single web worker gets unwieldy quickly: you have to encode not only the parameters but also the operation in the message, and you have to do bookkeeping to match responses to requests. That complexity is likely why web workers haven't been adopted more widely.

But if we could remove some of the difficulty of communicating between the main thread and web workers, this model could be a great fit for many use cases. And, luckily, there's a library that does just that!

Comlink is a library whose goal is to let you use web workers without having to think about the details of postMessage. Comlink lets you to share variables between web workers and the main thread almost like other programming languages that support threading.

You set up Comlink by importing it in a web worker and defining a set of functions to expose to the main thread. You then import Comlink on the main thread, wrap the worker, and get access to the exposed functions:

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

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

The api variable on main thread behaves the same as the one in the web worker, except that every function returns a promise for a value rather than the value itself.

What code should you move to a web worker?

Web workers don't have access to the DOM and many APIs like WebUSB, WebRTC, or Web Audio, so you can't put pieces of your app that rely on such access in a worker. Still, every small piece of code moved to a worker buys more headroom on the main thread for stuff that has to be there—like updating the user interface.

One problem for web developers is that most web apps rely on a UI framework like Vue or React to orchestrate everything in the app; everything is a component of the framework and so is inherently tied to the DOM. That would seem to make it difficult to migrate to an OMT architecture.

However, if we shift to a model in which UI concerns are separated from other concerns, like state management, web workers can be quite useful even with framework-based apps. That's exactly the approach taken with PROXX.

PROXX: an OMT case study

The Google Chrome team developed PROXX as a Minesweeper clone that meets Progressive Web App requirements, including working offline and having an engaging user experience. Unfortunately, early versions of the game performed poorly on constrained devices like feature phones, which led the team to realize that the main thread was a bottleneck.

The team decided to use web workers to separate the game's visual state from its logic:

  • The main thread handles rendering of animations and transitions.
  • A web worker handles game logic, which is purely computational.

OMT had interesting effects on PROXX's feature phone performance. In the non-OMT version, the UI is frozen for six seconds after the user interacts with it. There's no feedback, and the user has to wait for the full six seconds before being able to do something else.

UI response time in the non-OMT version of PROXX.

In the OMT version, however, the game takes twelve seconds to complete a UI update. While that seems like a performance loss, it actually leads to increased feedback to the user. The slowdown occurs because the app is shipping more frames than the non-OMT version, which isn't shipping any frames at all. The user therefore knows that something is happening and can continue playing as the UI updates, making the game feel considerably better.

UI response time in the OMT version of PROXX.

This is a conscious tradeoff: we give users of constrained devices an experience that feels better without penalizing users of high-end devices.

Implications of an OMT architecture

As the PROXX example shows, OMT makes your app reliably run on a wider range of devices, but it doesn't make your app faster:

  • You're just moving work from the main thread, not reducing the work.
  • The extra communication overhead between the web worker and the main thread can sometimes make things marginally slower.

Considering the tradeoffs

Since the main thread is free to process user interactions like scrolling while JavaScript is running, there are fewer dropped frames even though total wait time may be marginally longer. Making the user wait a bit is preferable to dropping a frame because the margin of error is smaller for dropped frames: dropping a frame happens in milliseconds, while you have hundreds of milliseconds before a user perceives wait time.

Because of the unpredictability of performance across devices, the goal of OMT architecture is really about reducing risk—making your app more robust in the face of highly variable runtime conditions—not about the performance benefits of parallelization. The increase in resilience and the improvements to UX are more than worth any small tradeoff in speed.

A note about tooling

Web workers aren't yet mainstream, so most module tools—like webpack and Rollup—don't support them out of the box. (Parcel does though!) Luckily, there are plugins to make web workers, well, work with webpack and Rollup:

Summing up

To make sure our apps are as reliable and accessible as possible, especially in an increasingly globalized marketplace, we need to support constrained devices—they're how most users are accessing the web globally. OMT offers a promising way to increase performance on such devices without adversely affecting users of high-end devices.

Also, OMT has secondary benefits:

Web workers don't have to be scary. Tools like Comlink are taking the work out of workers and making them a viable choice for a wide range of web applications.

Hero image from Unsplash, by James Peacock.