JavaScript has been around for almost as long as the Web as a platform itself. Being famously developed in 10 days back in 1995, JavaScript was never built to run and manage an entire web page like we do today; its purpose was much more modest.
Over two decades later, post browser wars and standardization, the Web platform kept getting more and more capable, to the point where we now can dynamically fetch all of our page's contents after the initial page structure is loaded, access the device's hardware like Bluetooth and NFC, and even install entire websites as apps on a smartphone.
But while features and device capabilities as whole kept involving, one thing never changed: JavaScript has always been single-threaded. This means that all the work that you instruct your page to do will execute on what is known as the main thread, and it will need to happen sequentially, one task at a time. This means managing your HTML elements, creating new ones, loading new scripts, communicating with external APIs, reacting to user input (mouse, keyboard, scroll, …), etc. So… what if any of those takes a particular large amount of time…? We've all been there:
JavaScript has had ways to deal with the synchronous/asynchronous nature of the code we write for it. A way to mitigate this has, for a long time, been through the use of callback functions, i.e. functions that you pass in as arguments to some other function, and whenever that one finishes doing whatever it's supposed to do, then the one you passed in as an argument gets executed.
This approach, while effective, has had its own problems, especially the so-called 🔥callback hell🔥, in which some starting function has a callback function, which has a callback function, which has a callback function, until infinity and beyond. Eventually we've moved on to Promises, but none of that is the point of this article.
The point is that, due to JavaScript's single-threaded nature, no matter how many callbacks or promises you have, if any of those individual calls takes too much time to execute because their work cannot be further split into smaller chunks, the main thread might still get too busy to smoothly further process your page, resulting in sluggish or unresponsive pages. This becomes particularly noticeable on lower-end devices like cheaper smartphones and laptops, which actually most people have; only a small part of the general population has high-end phones, which, I guess, is why they're called "high-end".
Multi-threading on the Web?
While other platforms have their own way of handling multi-threading with their own complexities (like using semaphores and mutexes for determining which processes can access which variables at which time as to prevent race conditions – whew, that was a mouthful), JavaScript takes a higher-level approach through the use of ✨Web Workers✨, also known as Dedicated Workers. Workers run parallel to the main thread but they can't share variables. Others might see it as downside, but I find it quite positive, as it inherently removes all of the complexity mentioned above while preventing weird side-effects resulting from different scripts manipulating the same set of data at the same time, and all while allowing computing-intensive work to run off the main thread, so it remains free for other tasks that your users actually feel, like page responsiveness.
What can't Web Workers do?
Let's start with the bad. There are 2 major things I see that might put developers off from using web workers.
Web Workers can't access the DOM
That's right, workers have no knowledge of the DOM whatsoever. This means they can't access the HTML at all, nor access global variables. But note that this is by design. The UI is supposed to be handled by the main thread exclusively, and bad things can happen is more than one thread would attempt to manipulate the same data (in this case, the same DOM elements) at the same time. In practice, this means that, for instance, there's no way for the code in a worker to write its results directly into a <div>
element. Instead, the worker must send its data back to the main thread, and let the main thread take care of displaying the results. I like to think of this dynamic in the context of a car. Cars only have a single steering wheel (the proverbial variable) and it's exactly in front of the driver's seat (the main thread), not in the center of the car itself, where it could be grabbed by any other passenger (other threads). The other passengers can still assist the driver by indicating where to go, but ultimately only the driver steers the vehicle.
The interface to a Web Worker
The only way to talk to a web worker is through the window.postMessage()
method, which in itself is not strange; this is how browsers enable communication between different contexts of the same page, like a pop-up window that the page might have spawned, or an iframe embedded within it. A worker is just yet another context. You'll find a practical example further down.
What can Web Workers do?
Once you saw above that a worker can't access the DOM you might have thought that there's not much usefulness to them, but that's not true at all. Workers have a rich list of browser features that they can freely access and use in order to accomplish their goal. Here's a small list of features and APIs they can take advantage of:
- Import external scripts
- Make XMLHttpRequest/fetch requests
- Use setTimeout() and setInterval()
- Spawn other workers
- Use IndexedDB, Notifications API, Web Crypto API, WebAssembly, WebSockets, WebGL, OffscreenCanvas, ImageData…
- Terminate (close) themselves when you deem they are no longer needed
How do we do it?
As already mentioned, web workers run in a different context than the main page, meaning we must use the postMessage()
method for all communication with a worker.
The way this typically works is:
- Main thread instances worker, if not already instanced
- Main thread listens to worker's "message" events
- Main thread calls
postMessage()
to send some information to the worker - When instanced, a worker listens to "message" events, which will trigger when the main thread calls
postMessage()
- Worker does whatever task it's supposed to do
- When done, worker calls
postMessage()
and sends some information back to the main thread - Main thread's "message" event listener triggers with response from worker, and acts accordingly
Let's take a look at a simple example.
Below there are two scripts. One is running on the main thread; it's the kind of script you'd have loaded or running directly in a <script>
tag. The other one is the worker script, which always has to be self-contained in its own file.
In this example, the main script creates a new Worker instance by referencing the worker script file, listens to its "message" events, and then calls postMessage()
with the string "John Doe" as an argument. On the worker script's side, the worker immediately sets an event listener for "message" events, where it will receive whatever string the main script sends it, build the string "Hello [name]" where "[name]" is whatever string the main script provided, and will then send that new string back to the main thread, which will print it to the console.
const worker = new Worker('hello-worker.js');
// Add listener for when the worker replies
worker.addEventListener('message', (event) => {
console.log('Worker said: ', event.data);
});
// Send data to worker
worker.postMessage('John Doe');
// This is the entry point to the Worker
self.addEventListener('message', (event) => {
// Do some heavy work here or call other functions to do it
const incomingName = event.data;
const result = 'Hello ' + incomingName;
// Send result back to main thread
self.postMessage(result);
});
And that's it! See? It wasn't that complicated at all… yet…! You can find here a running example of the code above.
Indeed, this is the basic on using web workers; they are very powerful, as you can use a lot of APIs and offload expensive processing to a different thread, but you will always have to go through the postMessage()
"gate" to handle the communication between a workers and whoever is communicating with it. This necessarily means that if you need your worker to do different kinds of work, your structure will have to grow in size and complexity accordingly, which can get unwieldy…
For instance, in the next example, our worker is capable not only of returning a "Hello [name]" string, but also returning the current date.
const worker = new Worker('multi-worker.js');
worker.addEventListener('message', (event) => {
console.log('Worker said: ', event.data.resp);
});
const payload = {};
// Action 1
payload = {
action: 'hello-world',
arg: 'Jane Doe'
};
worker.postMessage(payload);
// Action 2
payload = {
action: 'current-time'
};
worker.postMessage(payload);
self.addEventListener('message', (event) => {
const payload = event.data;
// Prepare response object
const response = {
action: payload.action,
resp: null
};
switch(payload.action) {
case 'hello-world':
response.resp = 'Hello ' + payload.arg;
break;
case 'current-time':
response.resp = new Date();
break;
default:
response.resp = 'Unknown command';
}
self.postMessage(response);
});
As you could see, we could no longer simply pass our simple argument for the "Hello [name]" string to the postMessage()
object on the main string. Instead, we had to create a payload
object which would let the worker know what kind of work to do and the arguments to use, if any. Then, on the worker's side, it had to resort to a switch
statement in order to run the appropriate action based on the payload
object it was given. Here you'll find that same code in a running example.
It comes to no surprise that the more your worker can do, the more complex the workflow for managing it will become. Depending on the use case, this might be fine, or you may simply have to resort to more specialised workers.
Side note: while the above might be fine for simpler use cases, I've shown how having a worker that can perform many different tasks can get unwieldy, and for these cases I recommend using Comlink, "a tiny library that removes the mental barrier of thinking about postMessage
and hides the fact that you are working with workers". Showing how to work with Comlink is beyond the scope of this article, but I can tell you it's very useful for interacting with your worker as if it were just a normal part of your other code running on the main thread. For instance, you could create a standard Class object – let's say, an Analytics object with some public and private methods in a worker –, let Comlink wrap it, and create an instance of it. You could then call one of the public functions of your "analytics" object – say, analytics.compileAndSend()
– and the function would actually be executing in the running web worker, even though you never explicitly write any code related to events or postMessage()
.
How well-supported are web workers?
Web workers are VERY well supported. They've been around since 2007 and supported across all major browsers since 2012. I'd say that if a feature is supported both in Internet Explorer 10 and any modern Safari, it's pretty safe to use everywhere.
In short
The main thread is the lowest common denominator when it comes to where JavaScript in a web page ends up running. This means it's often needlessly overworked which can cause your page to become janky, sluggish or even worse: unresponsive. So consider using web workers to move non-DOM computing-intensive tasks away from the browser's main thread and you might just see a difference.
That being said, note that web workers are not a solution for bad coding or loading too much code in a page, nor are they the solution for all of your page's responsiveness problems; they are merely another tool at your disposal.