So you want to use a Web Worker
You may have heard you can do almost anything with JavaScript. Like, even making coffee is not out of the question. And you have heard correctly, not only is JS a really versatile language to begin with, it’s widely used as a Front-End and Back-End language on many a web app. You may have also heard that JS can be a bit strange sometimes. This is true as well. Javascript has quite a lot of edge-case behaviour which can quickly bite you in your behind if you’re not careful. You may have heard, and yes, I know I’m repeating myself here, that JS is single-threaded. And that’s true as well...
...Kinda. There’s a way to use JS with more threads. It’s possible, but definitely not as convenient or as simple in some other languages. As a matter of fact, it often doesn’t make sense to do it and it comes with some caveats you might not want to settle for. But, why even try, right? JS is only a scripting language that front-end developers use for changing button colors and send a request or two to the server. Why would you need a separate thread for that? And while we’re at it, how to do it anyway?
So, you want to use a web worker?
Why and Where
As we’ll see, creating a Web Worker is not trivial and it requires some time, so you should have a good reason for using it.
RAIL model guidelines specify a button response should ideally be below 100ms. If you’re dealing with animations you have way less time - you should ideally stay below 16ms. If the main thread is blocked for longer than that, CPU driven animations can become choppy really fast (more on this later). Now, modern CPUs are pretty good at dealing with the usual stuff you do on a web page, but not all web apps are your normal web page, are they now.
First, let’s see a couple of reasons why having a separate thread in the web app makes sense:
- Heavy computational work
When you need to do some heavy computational work inside your web app and the whole thing is becoming sluggish. A huge amount of calculations on the main thread, like manipulating picture data or calculating physics in your game, takes some time even on top-shelf CPUs, not to mention low-end mobile devices a lot of people use. - A lot of network requests
This is often a result of a poor back-end implementation. Regardless, creating huge amounts of network requests can block your main thread for sure. - Aggregation of data
Again, these kinds of operations are often easier to implement on the backend. Sometimes, however, this is not an option, and if you’re stuck with aggregating gigantic arrays of objects you might as well do it in a way your app performance doesn’t suffer.
You might have picked up that use-cases for a web worker are more of an esoteric nature, but they do exist. A good example of this is Google’s own Firebase. Let’s say you need to fetch 200 specific documents and display them on the page. Firebase, at the time of writing this article, requires you to fetch all these documents on a one-by-one basis. Not fun, when you’re dealing with large amounts of documents.
Things can mostly be boiled down to this: Workers help with stuff you shouldn’t be doing, but are doing it regardless. Ha.
Putting the humor aside, using a web worker doesn’t make sense for most of the well-structured web pages. A modern computer can bulldozer through most of the calculations you need to do on the frontend with a lot of power to spare. But since we don’t live in a perfect world and there are use cases for using web workers, let’s start with exploring what blocking of the main thread can do to your web page.
Open the above CodePen and get some prime numbers. You’ll notice that the red and black square stop moving right after the button was clicked. Now get some prime numbers again, and try to resize your browser’s window. The page is not being resized, any button inside the webpage is unresponsive and hover animations have stopped working. What a horrible experience, right?
This is because calculating prime numbers is pretty computationally expensive and doing it on the main thread completely blocks said thread until the required amount of prime numbers is calculated. Now, this example is obviously an exaggeration, because nobody is calculating prime numbers in bulk inside a web app, but even shorter workloads will make your webpage noticeably more stutter-prone.
You probably also noticed that the blue box kept moving as if nothing was happening. Having the main thread blocked isn’t necessarily noticeable. To understand what’s happening here, we first need to look at how a web page is rendered.
When a browser downloads the JS bundle, it first needs to parse it. After parsing, the browser calculates which styles are applied to which elements - this is called a Style calculation. Then, it needs to calculate the Layout of the page, like width and height of the elements. Next up is Paint. This is stuff like shadows, radius, font color, background color, … A two-stage process begins with creating a list of draw calls. Essentially it’s calculating how many objects need to be drawn on the screen. The second stage is calculating actual pixels of the individual elements, called rasterization. At last, a Composite is created. Simplified, it’s a flat image of all the elements on your page. This is what you eventually see on the monitor. If, for example, Layout changes, Paint and Composite are re-calculated as well.
Let’s see how the CSS animations were created in the examples:
Red Box
@keyframes marginLeft {
from {
margin-left: 0px;
}
to {
margin-left: 300px;
}
}
Blue Box
@keyframes transformLeft {
from {
transform: translate(0px);
}
to {
transform: translate(300px);
}
}
The animation of the red box was done with changing the margin property which is calculated on the main thread. Because any margin changes affect Layout, the browser has to re-calculate Paint and Composite as well, which makes an already expensive change take even more time.
In contrast, the animation of the blue box was done with a transform property that only triggers Composite recalculation, which is much cheaper compared to Layout or Paint. This is because of two things: it’s GPU accelerated, and it runs on a separate thread (Chrome, for example, calls it the Compositor Thread).
For more details on page rendering, you should read about the pixel pipeline.
When you use a transform property, the only thing that changes in the next frame is a layout of already calculated elements (a div moves, is shrunk, rotated...), and the result is rasterized again. No rendering engine is born the same, and knowing which CSS properties trigger which layer recalculation is too much information to know by heart.
The takeaway here is, you should be using CSS properties that only affect the Composite layer recalculation where possible. It’s super important for the smoothness of your UI, especially for animations. It's way smoother than anything you can do with standard positioning, easily reaching that 60fps goal. It’ll work better on just about any computer out there - mobile devices included.
Now we know what happens when the main thread gets blocked for a period of time.
How it’s done in Swift and Java
You may very well be a web developer, but knowing how green the grass on the neighbours field is, hurts no one:
DispatchQueue.global(qos: .background).async {
// Do something off the main thread
DispatchQueue.main.async {
// Back on the main thread (render result to UI)
}
}
This is Swift, the language of choice for most iOS developers. You simply dispatch the work to another thread, where CPU works on it. You can even declare a QOS (Quality Of Service) - this means you can decide how important the work is and how fast you need it to be completed. After the task is complete, you can simply use the new data on the main thread via a closure function. Fairly simple indeed.
What about Javascript?
Creating a Web Worker
To use Javascript as a multithreaded language, you have to create a Web Worker. If that wasn’t obvious from the title of this post. A web worker is essentially a JS engine running in a separate thread. Web workers were introduced in the HTML 5 spec, and are quite well supported. A web worker is generally expected to be long-lived as it has a fairly high start-up performance cost, so you probably wouldn’t want to create and kill it every time you’re trying to have something calculated off the main thread. This means you’re probably going to create a worker when the app starts, and stick with it during the duration of your apps lifecycle. It also consumes a fair amount of memory, but since these are web apps we’re talking about - of course it does.
Before we lose ourselves inside the Worker, let’s glance at the Shared Worker. With Shared Worker, you can access one instance of the Web Worker across multiple app instances running locally in your browser. This means, for example, you could run the same web app in three different tabs, each app instance being able to access the other two’s local data via the Shared worker. And while this is extremely interesting, it’s officially only supported on Chrome and Safari, extremely edge-casey and as such not as useful.
Let’s create a web worker then.
import Worker from "worker-loader!./worker.js"
const worker = new Worker()
const anotherWorker = new Worker()
const allTheWorkers = {
here: new Worker()
there: new Worker()
everywhere: new Worker()
}
The most used (and recommended) way of doing it, is to write all the code in a separate file and just import and initialize it in the app. As you can see, you can create many workers, although this might make your app even more memory hungry. In the above example, I used Create React App, a fantastic boilerplate with all the basic configuration needed for creating a React app. I also used worker-loader to easily import my worker into the bundle.
Creating a worker on the fly inside your code is also possible, although just because you can doesn’t mean you should. You have to actually deconstruct the function body into a Blob, then transfer it to the Worker where you re-assemble and invoke it. Super hacky indeed.
Make it move
So, we just created a Web Worker. Now what?
Every worker gets its DedicatedWorkerGlobalScope object, accessible via the global `self`, instead of `this`. That means no global `document`, or `window` variables, so you don’t have the same global scope as you normally do in JS, meaning there are a few methods you don’t have access to. These are mostly edge cases, and a Worker can do pretty much everything you can do on the main thread, with a catch: You can’t directly manipulate the DOM (that’s the Document Object Model, the stuff you actually see on the webpage). Now, that doesn’t mean you can’t manipulate some stuff directly from inside the worker, but let’s talk about this a bit later.
Workers are really talented. They can, of course, use any normal JS stuff like maps, reduces, Math prototypes and so on. They can also create network requests. It doesn’t end there, though. Not only can they use `requestAnimationFrame`, they can generate an Offscreen Canvas, which is pretty similar to the regular canvas, but it’s rendered offscreen, not attached to the DOM, and therefore offers slight speed improvements over the regular canvas.
To send a message to a Web Worker, you have to use the postMessage method. The same goes for sending data back to the main thread. To receive a message, listen to a message event. You can send any type of data to a worker, be it a primitive, like a number, an object, or even a large array. These kinds of data are copied, rather than shared. They can also receive Transferable objects, like an Array Buffer or Offscreen Canvas. As their name suggests, they are transferred to the Worker, meaning you actually move them from the main thread to the thread your Worker lives in.
self.onmessage = async e => {
const payload = await doWork(e)
self.postMessage(payload)
}
const worker = new Worker()
worker.postMessage(...)
worker.onmessage = ({ data }) => {
console.log(data)
}
Workers can do a lot, but all communication and the code inside the worker is up to you to implement. This is an example of a simple worker with a single method that’s executed on every call.
For anything more powerful or flexible, you’re left to your own devices. You need to be able to keep track of the requests you sent to the worker and handle the data being returned. While this is pretty straightforward for a worker that only does one thing, it can become a bit challenging when dealing with a complex Worker that can do many different asynchronous tasks.
self.onmessage = async e => {
const { type } = e
if (type.e === 'methodOne') {
const payload = await methodOne(e)
self.postMessage(payload)
} else if (type.e === 'methodTwo') {
const payload = await methodTwo(e)
self.postMessage(payload)
}
}
worker.postMessage({
type: 'methodOne',
payload
})
worker.onmessage = ({ data }) => {
console.log(data)
}
This is an example of a bit more complex worker code, where different methods can be invoked inside the worker. This also quickly becomes unmanageable, so you’ll probably need to take things even further.
Real Life
Let’s check out two real-world examples of how a web worker can keep your page responsive even when the CPU is hard at work.
The first one is an iteration on the example you saw earlier, with the worker calculating prime numbers, and displaying each one every time it’s found. Get yourself some prime numbers and come back.
You might have noticed that this example behaves a bit different than the one without a Web Worker. Even though the code for calculating prime numbers is similar to the one from the previous example, the only exception being it runs on a separate thread, the page doesn’t become unresponsive while the calculations are happening. In this case the main thread is only tasked with updating the UI and animations. While the Worker is calculating the next prime number, the main thread sits practically idle, save for calculating a new position of the red and black boxes. When a prime number is found, the worker sends it to the main thread where it’s instantly displayed. Not only are all boxes moving as expected, but the website is also completely responsive as if nothing particular is happening.
The second example is an image manipulation application. It’s nothing groundbreaking, but it’s an excellent example of what Web Workers excel at.
There are two features: it can brighten or darken an image, or it can blur it. The point, though, is it all happens with JS, which means it runs on a CPU instead of the GPU. This means it has to iterate through all of the pixels in the image and change the color of each individual pixel. Of course, doing this with the GPU is almost instantaneous, but slapping a CSS blur filter on a Canvas is WAY less fun than doing the dirty work yourself.
Remember how you can’t manipulate the DOM from inside the worker? Well, that’s technically true, but you can still change some of the stuff on your page.
When the example app loads, it creates a worker and transfers control of the HTML Canvas element to the worker. Every time you upload an image, the main thread uses a createImageBitmap method to, well, create an image bitmap, and transfers it to the Worker as a Transferable Object mentioned previously. The worker then renders it to the canvas you can see. Of course, every image manipulation is done from inside the worker. Not only is the worker performing calculations for image manipulation, but it also renders the image by itself. Neat! The main thread stays as responsive as ever, only needing to update the UI because of those pesky boxes.
Take away
Web Workers are not a magic solution to the responsiveness of your application. They are just a tool that, like many others, if used correctly, will help you make your app more pleasant to use. In a world where all devices have at least two cores, but double or triple of that is very common, it really makes sense to write a multithreaded program. On the web, however, achieving this goal is not always an easy solution. Web workers, even if a bit clunky, are a really powerful tool for a web developer. If you actually need them, of course. Whether you do or not - it’s up to you to decide.
Extra info
In CRA, I had to eject the project in order to set a small webpack config flag because webpack wouldn’t properly load my Worker otherwise. It might be possible to do it without ejecting, but I didn’t investigate any further. CRA team mentioned something about supporting the worker-loader out of the box, but as of writing this post that didn’t happen yet.