#tech
#javascript
#workers
6 min read

Multithreading in JavaScript with Web Workers

Andriy Obrizan

JavaScript is single-threaded by default, and doing heavy calculations will make the application unresponsive. When it’s taking too long, the browser will even show a warning and offer the user to terminate the hanged page. Developers have been solving this problem in desktop applications for a long time with multithreading.

Web Workers bring multithreading to JavaScript web applications. Different parts of code can run simultaneously without blocking each other and communicate using message passing techniques. Unlike asynchronous code, where each callback still waits for the opportunity to run in the main thread, multiple modules are executed together in parallel.

When to use Web Workers

Web workers are handy for doing long computations in the background.

Typically, where everything runs in one thread, those computations would block everything else. Not just another Javascript code, but everything - DOM updates, CSS animations, mouse clicks, hovers, and even scroll. The page gets unresponsive or periodically freezes for short random intervals if the blocking code doesn’t take that long.

Probably everyone has seen this on the web, and no one wants that behavior to screw user experience in their applications.

Fortunately, you can solve this problem by offloading intensive tasks to web workers. You can start them at any time and then exchange messages with the main thread. For heavy computations, for example, the main thread will only pass the task to a worker thread and remain responsive. The worker thread will return the results on completion and may optionally signal the progress or pass partial results.

An application will remain responsive as all the heavy code is now running in parallel on a different thread.

According to caniuse.com, all major browsers support web workers and will be available for 98% of people on the internet:

Web Worker Example

Using web workers is pretty simple. You’ll need to create a separate file for the worker code and load it into your application by the URL. Let’s create a sample worker.js file:

self.addEventListener('message', handleMessage);

function handleMessage(e) {
  self.postMessage({ ...e.data, received: Date.now() });
}

This worker will send all the messages back, adding a field received with the receiving timestamp.

Now, let’s add it to our main application:

const worker = new Worker('worker.js');
worker.addEventListener('message', handleMessage);

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

worker.postMessage({ test: 42 });

This code will run the web worker from worker.js, log every message from it to the console, and post a { test: 42 } message back.

The worker will be running in a separate thread, listening for messages. It will receive the test message from the main thread and reply with all the fields, adding a received timestamp to the object. The main application also listens for messages from the web worker, logs it, and responds.

Limitations of Web Workers

You might be wondering why we should put web workers in a separate file. JavaScript is still single-threaded, and the worker code gets executed in a completely different context, totally isolated from the UI thread. Only the messages can cross boundaries. Well, not even the messages themselves, but rather their copies.

The messages have to be JSON serializable to reach the other side, meaning you can’t pass functions, object references, etc.

Workers have no access to the window object, and they have self instead. You can’t perform DOM manipulations directly from workers as it’s in the UI thread context. The workaround is simple - you can pass messages to the main thread and react to those messages with DOM updates.

There’s no way to share the memory with workers easily. The SharedArrayBuffer let’s share binary data, but not JavaScript objects.

Those limitations make the flow much more straightforward. Just post asynchronous calls and listen for messages in callbacks - something JavaScript applications already do all the time. Most multithreading problems in other runtimes come from shared resource access and all the synchronization techniques required. Isolated workers who communicate using messages are an excellent parallel programming pattern that lets scale computations to multiple cores or even machines if necessary. Unless the performance is super-critical, it’s more than enough for most applications.

Using Web Workers with WebPack

Most modern web applications use WebPack to bundle all the javascript into one or multiple bundles. Since Web Worker code has to be in a separate file, ideally bundled with all its dependencies, you have to configure WebPack to do it.

Fortunately, there’s a worker-loader package just for that:

npm install worker-loader --save-dev

Now you can use it inline it when importing the worker:

import Worker from "worker-loader!./Worker.js";

or add a separate configuration rule in webpack.config.js to tell WebPack when to apply the loader:

module.exports = {
  module: {
    rules: [
      {
        test: /\.worker\.js$/,
        use: { loader: "worker-loader" },
      },
    ],
  },
};

The usage is pretty straightforward:

const worker = new Worker();

worker.postMessage({ test: 42 });
worker.onmessage = function (event) { console.log(event); };

worker.addEventListener("message", function (event) { console.log(event); });

If you’re looking for more magic, there’s also workerize-loader package that lets quickly call every function from the worker file. The signature for async functions will remain the same and wrapped synchronous functions will return a Promise instead.

worker.js:

export function loop(time){
  const start = Date.now();
  let count = 0;
  while (Date.now() - start < time) count++;
  return count;
}

Now in the main application:

import Worker from 'workerize-loader!./worker'
 
const worker = new Worker();
 
worker.loop(500)
  .then(count => console.log(`Ran ${count} iterations`));

Since there are not many different worker files in an actual application, we prefer the inline syntax for both loaders over additional WebPack configuration. It plays nicely with create-react-app, and there’s no need to eject.

Conclusion

Using WebWorker will make the UI snappy and responsive, providing a much better experience to the users. It opens new horizons of applications now possible on the web.

You can do heavy calculations necessary for BI apps, photos and video editors, trading software, and other applications directly on the frontend. Even sophisticated AI or accurate physics engines for games can work in separate JavaScript threads.

At LeanyLabs, we’ve developed an app that lets users quickly slice & dice large amounts of data, generating dynamic reports directly in the browser. Achieving good performance was possible thanks to Web Workers.