Communicating between Web Workers via MessageChannel

[2017-01-15] dev, javascript, clientjs, concurrency
(Ad, please don’t block)

Occasionally, you want Web Workers to communicate with each other. Doing so is not obvious as most Web Worker examples are about communicating between the main thread and a Web Worker. There, one uses postMessage() to send messages directly to the Worker. Alas, that doesn’t work for communicating between two Workers, because you can’t pass references to Workers around.

MessageChannel  

The solution is to establish a channel between the Workers:

const channel = new MessageChannel();
receivingWorker.postMessage({port: channel.port1}, [channel.port1]);
sendingWorker.postMessage({port: channel.port2}, [channel.port2]);

We are creating a new MessageChannel and sending references to its two ports to two Workers. Every port can both send and receive messages. Note the second parameter of postMessage(): It specifies that channel.port1 and channel.port2 should be transfered (not copied) to the Workers. We can do that because MessagePort implements the interface Transferable.

Posting messages  

sendingWorker posts messages and looks as follows:

const sendingWorker = createWorker(() => {
    self.addEventListener('message', function (e) { // (A)
        const {port} = e.data; // (B)
        port.postMessage(['hello', 'world']); // (C)
    });
});

The tool function createWorker() for inlining Worker code is explained later.

The Worker performs the following steps:

  • Line A: Listen to messages sent to you from the main thread.
  • Line B: The first and only such message is an object whose property port holds the MessagePort.
  • Line C: Send data over the channel, via port.

Receiving messages  

receivingWorker first receives its port and then uses it to receive messages:

const receivingWorker = createWorker(() => {
    self.addEventListener('message', function (e) {
        const {port} = e.data;
        port.onmessage = function (e) { // (A)
            console.log(e.data);
        };
    });
});

In line A, we could also have used port.addEventListener('message', ···). But then you need to explicitly call port.start() before you can receive messages. Unfortunately, the setter is more magical here than the method.

The nice thing about receiving messages is that the channel buffers posted messages. Therefore, there is no need to worry about race conditions (sending too early or listening too late).

Inlining Web Workers  

The following tool function is used for inlining Web Workers.

function createWorker(workerFunc) {
    if (! (workerFunc instanceof Function)) {
        throw new Error('Argument must be function');
    }
    const src = `(${workerFunc})();`;
    const blob = new Blob([src], {type: 'application/javascript'});
    const url = URL.createObjectURL(blob);
    return new Worker(url);    
}

For older browsers, using separate source files is safer, because creating Workers from blobs can be buggy and/or unsupported.

Further reading  

Acknowledgements: Tips on Twitter from @mourner, @nolanlawson and @Dolphin_Wood helped with this blog post.