Building Multi-use Web Workers

In modern web applications we build focused components that are combined together to become a fully functioning app. We use modules to organize our components into smaller pieces. This is fairly universal no matter what framework you use (or if you don't use a framework at all).

If you'd like to take advantage of the performance benefits of using web workers you are forced to think about code organization outside of the context of modules. Unfortunately, at least today, it is not possible to import a web worker like you can modules that exist within the same thread.

Web workers expose an event-based interface for communication. You use postMessage to send message to a worker, postMessage to send a message back from a worker, and event listeners to receive messages on both sides. It looks like this:

app.js

const worker = new Worker("./worker.js");

worker.postMessage({
  message: "ping"
});

worker.addEventListener("message", ev => {
  console.log(ev.data.message); // -> "pong"
});

worker.js

self.addEventListener("message", ev => {
  console.log(ev.data.message); // -> "ping"

  postMessage({ message: "pong" });
});

As you can see, postMessage takes an argument that represents the message. The message can be any type that can be cloned, as JavaScript cannot share memory between threads (with some exceptions that are beyond the scope if this article). It's common to use an object as the argument to postMessage for reasons we'll discuss next.

Multi-use workers

The problem with the above example code is that it only works if the worker is single-use. However, because web workers are still relatively expensive to launch, you'll likely want them to perform more than one task.

At this point you need a way to segment different types of messages that a worker can handle. I typically do this by providing a type property in my messages. This is meant to be similar to the type property in Events. It looks like so:

worker.js

self.addEventListener("message", ev => {
  let msg = ev.data;
  switch(msg.type) {
    case "do-thing":
      doThing(msg);
      break;
    // TODO handle other cases

    default:
      console.warn("Oops", msg.type,
        "is not a supported message type.");
      break;
  }
});

app.js

worker.postMessage({
  type: "do-thing",
  more: "stuff",
  forThis: "type of message"
});

As you can see, we are able to support different types of messages within a web worker by providing a type as part of the message, and then using a case statement to route the message to the appropriate handler.

This is the very basics of how you are able to handle multi-use worker. If you understand this you can build your own patterns which fit your specific needs. The rest of this article deals with generalizing the pattern to reduce the coupling that our switch statement presents.

Abstracting the Message Router

In the previous section we built a message router. However the event listener needs to know about each message that might be received. We can generalize this so that code running within the worker can register to be the handler for a message. If this sounds like event listeners; it is pretty much exactly like that. However in our case we are only going to allow one listener for each message.

const router = {
  _handlers: new Map(),

  on(type, cb) {
    this._handlers.set(type, cb);
  },

  handle(msg) {
    let cb = this._handlers.get(msg.type);
    if(cb) {
      cb(msg);
    } else {
      console.warn('There is no handler for the message:', msg.type);
    }
  }
};

self.addEventListener('message', ev => {
  router.handle(ev.data);
});

This code is pretty straight forward; we save callbacks in a Map, registered with on, and emit messages with the handle method. Now anywhere within the worker handlers can be registered like so:

router.on('fetch-todos', async (msg) => {
  let res = await fetch('/api/todos');
  let todos = await res.json();

  postMessage({
    type: 'fetched-todos',
    todos
  });
});

Now we can move our code that handles each type of message our router might receive to separate modules and start to organize the worker code similar to how we organize our main (window) thread code.

Routing Messages in the Window

This article mostly talks about what code is written for the web worker. But as you can imagine, very similar code is needed in the window thread as well. For each worker created you'll likely need a router like the one we wrote above, passing that to each component that needs to communicate with the worker. To that end you might encapsulate the router in a JavaScript class, and give it a method to message the worker.

It Gets More Complicated

While you can build out the basics of worker communication with just a little code like above, you'll quickly realize it's not enough.

One issue is that often the type of communication between the window and worker follows a request-response pattern. That is, one side (usually starting from the window) will send a message to the worker and expect a reply. Therefore, it needs to save information about the request so that when the response is received it can handle it.

This can be implemented by giving each request a unique id, and saving metadata about the request in a Map (often this is as simple as a callback). However, as you can imagine, this risks memory leaks if the worker never responds. You can work around this by setting up timeouts for when memory will occassionally be cleaned up.

One possible work-around to this problem is to send all of the metadata along with the request, and then sending it back as part of the reply. This is a little bit wasteful, as the worker gets a larger message than it really needs, and it might not be possible to clone all of the metadata you would like to send (functions cannot be cloned).

The request-response pattern is not as common when the worker is the initiator of messages, however, so you can partially avoid the problem by having your workers run the show. In a future article I'll talk more about the advantages of worker-first applications.

Libraries for Worker Communication

Previously I wrote about Leni, a small library I wrote that encapsulates the router pattern created in this article. It goes a little bit further, adding namespacing so that different parts of an application can safely send short message type names without as much concern for collisions.

Here are a couple of other libraries which generalize worker communication: