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:
- Web Workers execute in a completely separate JavaScript environment and don't share memory with your main thread, instead communicating with messages
- Workers have a different global scope than the main JS thread: there is no
window
object, and thus there is no DOM, nolocalStorage
and so on - 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)
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:
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:
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:
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!
Top comments (7)
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.
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...
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.
Thank you very much for your information; it is just what I was looking for. May I ask what software you use for your fantastic and quick website? I too intend to build a straightforward website for my company, however I require advice on a name and hosting. Asphostportal is said to have an excellent reputation for it. Are there any other options available, or can you recommend them?
Great, it's really a good tool, especially when we want to process a long queue system
Great article, very to the point and excellent example with the animation.
Thanks, glad you enjoyed it!