Workers are brilliant.
A single worker can give the main thread some much needed breathing room, extending an application’s processing capacity. The application’s UI hums along whether you’re processing video, auto-tuning some audio, or generating a dank meme.
But if workers are so great, why are they so under utilized? Are UI performance issues not common? That can’t be the case in a world full of JavaScript on mobile devices. So what gives? Well, I have a theory.
Web Workers aren’t under utilized not because we don’t need them. Web Workers are under utilized because they’re frustrating to manage.
The problem with Workers? Communication.
The other week I wrote an article on using Web Workers in a Firebase app. The results were incredible. Using a web worker took 95% of my JavaScript code out of the critical path while retaining the same functionality. Offloading the work improved page load and processing, but the development experience was difficult.
The premise of Web Workers is offloading some code to another thread. This power comes with responsibility. You now have to communicate between the main thread and the worker thread. How do you communicate with a worker? Messages.
Workers have a postMessage
method. This method is available on the main thread and within the worker’s global scope. It passes a message either to or from a worker’s scope. The message can be any JavaScript value or object, basically anything compatible with the structured cloning algorithm. Responding to a message is done through the message
event. Sounds simple, right?
Let’s say I want to send a number to a worker every second to have it doubled.
const worker = new Worker("./worker.js");
let count = 0;
setInterval(() => {
count = count + 1;
worker.postMessage({ action: "double", payload: count });
}, 1000);
The action
property is important. It’s how I tell the worker how to process this message. In this case, the worker will multiply the number by two and post it back to the main thread.
self.addEventListener("message", message => {
const { data } = message;
switch (data.action) {
case "doubled":
const number = data.payload;
const doubled = data.payload * 2;
self.postMessage({ command: "doubled", payload: doubled });
break;
}
});
I’m using a switch statement to manage multiple message types. The 'doubled'
action doubles the number and posts the message back to the main thread. Now I have to update the main thread script to listen to messages from the worker.
const worker = new Worker("./worker.js");
let count = 0;
setInterval(() => {
count = count + 1;
worker.postMessage(count);
}, 1000);
worker.addEventListener("message", message => {
const { data } = message;
switch (data.command) {
case "doubled":
const doubled = data.value;
console.log("doubled: ", doubled);
break;
}
});
I guess it’s not simple after all. This case is trivial, but imagine more complex work. Each task ran in the worker requires creating another branch in the switch statement. The code structure complexity increases as the worker gains more tasks.
Now there’s surely better structures than a mega switch statement. But the problem with workers remains. Communication between threads is difficult.
I want to directly call methods in the worker thread, rather than posting and listening to messages. But that’s not possible right? Well, not exactly.
Comlink, calling methods across barriers
The problem of communicating across barriers is not unique to workers. A Remote Procedure Call (RPC) is a concept of “calling” a method that is out of reach of the current context. This context could be a different scope, thread, or even a machine on a different network. The idea is to provide an abstraction to make your code look like it’s calling a method within reach.
What if our web worker code could look like this?
// expose a service for the main thread
self.service = {
double: value => value * 2
};
async function init() {
const worker = new Worker("./worker.js");
const doubled = await worker.double(2);
console.log(doubled);
}
init();
It’s not a fantasy, we can make it a reality with Comlink. Comlink is a small (1.6k) RPC library for workers. This guy made it.
His name is Surma, he’s a Google colleague of mine. Comlink provides an RPC layer to call methods from a worker on the main thread. Now, it’s not the exact same code above, but it’s pretty darn close.
importScripts("./comlink.global.min.js");
const service = {
double: value => value * 2
};
Comlink.expose(service, self);
async function init() {
const worker = new Worker("./worker.js");
const service = Comlink.proxy(worker);
const doubled = await service.double(2);
console.log(doubled);
}
init();
Comlink exposes the service in the worker thread. On the main thread Comlink proxies the worker to communicate with the exposed service.
Comlink acts like a generated switch statement. Event listeners are set up through the expose method. When Comlink proxies a worker it creates an object that understands how to send messages to the worker thread when methods are called.
It handles the mental and code complexity of communicating with workers. Comlink states that it’s goal is: “to make WebWorkers enjoyable.” I would say it does exactly that.
Comlink isn’t just for simple tasks like above. It works quite well in more advanced usages.
Comlink and callbacks
Comlink handles simple return values, but what about callback functions? The Firebase SDK is chock-full of callback functions. Let’s setup the worker to import Firebase and listen to a collection restaurants from the Firestore database.
importScripts("https://cdn.jsdelivr.net/npm/comlinkjs/comlink.global.min.js");
importScripts("https://www.gstatic.com/firebasejs/5.0.4/firebase-app.js");
importScripts("https://www.gstatic.com/firebasejs/5.0.4/firebase-firestore.js");
const app = firebase.initializeApp({
/* config */
});
const firestore = app.firestore();
const restaurants = {
subscribe(callback) {
const restaurantsCol = firestore.collection("restaurants");
restaurantsCol.onSnapshot(snap => {
// unwrap the data from the snapshot
callback(snap.docs.map(d => d.data()));
});
}
};
Comlink.expose(restaurants, self);
I can now listen to the Firestore listener exposed to Comlink.
async function subscribe(callback) {
const worker = new Worker("./worker.js");
const restaurants = Comlink.proxy(worker);
restaurants.subscribe(callback);
}
subscribe(restaurants => console.log(restaurants));
This looks great! But there’s a problem. The code above won’t work. It will give the following error:
Uncaught (in promise) DOMException: Failed to execute 'postMessage' on 'Worker': restaurants => console.log(restaurants) could not be cloned.
Functions don’t work with the structured clone algorithm. Does this mean we can’t use callbacks with workers? No. Comlink has us covered.
Comlink has a special method named proxyValue
. This method works around the limitations of structured clone by doing some magic with MessageChannel
s. Honestly, I don’t completely understand it, so I’m not going to try to explain any more ¯\_(ツ)_/¯
.
async function subscribe(callback) {
const worker = new Worker("./worker.js");
const restaurants = Comlink.proxy(worker);
restaurants.subscribe(Comlink.proxyValue(callback));
}
subscribe(restaurants => console.log(restaurants));
By using the proxyValue
method we can pass callback functions that are processed on the other thread. If that’s not magic, I don’t know what is.
Comlink is 🧙
Workers are great, but managing them? Not so much. Comlink is a big upgrade from dealing with postMessage
and the message event directly. It allows you to act as if you can call methods directly from other threads. The magic in Comlink is that it hides the worker communication from you and. It gives you the API you wish you had all along.
If you’re ready to move work off the main thread, give Comlink a try.