Messaging Between Tabs Using Service Worker

As of late I've been thinking a lot about Service Workers (sorry if this is getting boring!), predominantly in relation to the Cache API. For those of you who aren't familiar, Service Workers are a type of Web Worker that are shared between a domain scope, and can do cool things like intercept network requests and cache them. This is powerful because it means you can improve the performance of your app for commonly accessed assets and even go offline (as the Service Worker acts a network proxy). Alongside caching, Service Workers provide a host for other capabilities for features such as Push Notifications and also syncing data in the background using the Background Sync API. Not too shabby eh?

In this post I want to think about something slightly different. As mentioned Service Workers have this interesting property in that each Service Worker is registered per scope (by default the base location of the Service Worker script). This means multiple 'clients' (a "document in a browser context" or more simply tabs and windows) share the same Service Worker.

One side effect of this is that these clients can pass messages to the Service Worker and then propagate down messages to other open clients. I wanted to explore the potential of this capability a little more. I did a bit of research and found this fantastic blog post from Craig Russell about sending messages with Service Workers. I want to expand on Craig's work to take it a little bit futher into the realm of updating tab state. Under the assumption that we have correctly registered our Service Worker in the page, lets demonstrate how we might achieve basic message passing, and then see what kind of things that might allow us to do.

From the client code we need a function to allow us to post a message to a Service Worker. One misconception is that the data passed needs to be a string, but it can actually be any basic data type that is acceptable by the Structured Clone Algorithm. In short this is pretty much everything except Errors, Functions and DOM nodes. In theory if you needed to pass these things you could use JSON.stringify and JSON.parse but these present their own pitfalls. This aside let see how the message sending works:

function stateToServiceWorker(data) {
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage(data);
}
}

So this function covers sending from the client, what about receiving from the client? We could do something like this in our registration code to register for messages from our Service Worker:

if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("service-worker.js")
.then(function () {
return navigator.serviceWorker.ready;
})
.then(function (reg) {
// Here we add the event listener for receiving messages
navigator.serviceWorker.addEventListener("message", function (event) {
console.log(event.data);
});
})
.catch(function (error) {
console.error("Service Worker registration error : ", error);
});
}

This concludes our client side code for sending and receiving messages. Now what about our Service Worker? Firstly lets examine receiving messages:

self.addEventListener("message", function (event) {
// Receive the data from the client
var data = event.data;

// The unique ID of the tab
var clientId = event.source.id;

// A function that handles the message
self.syncTabState(data, clientId);
});

Now that we've received a message from the client, we need to know how to send it back to potential clients. We could probably inline this code but wanted to break it down for this demonstration:

self.sendTabState = function (client, data) {
// Post data to a specific client
client.postMessage(data);
};

Greg's post actually shows how you can send a message back to the client in question if you so wish. This can be done by sending a reference to a MessageChannels ports across and using some nice Promise callback wrapping, but for the sake of simplicity I'm omitting that here.

Now we can send some message to any specific client of our choosing, but how do actually call this function to access all the clients? We could do something like this:

self.syncTabState = function (data, clientId) {
clients.matchAll().then(function (clients) {
// Loop over all available clients
clients.forEach(function (client) {
// No need to update the tab that
// sent the data
if (client.id !== clientId) {
self.sendTabState(client, data);
}
});
});
};

So we've shown how to send and receive messages from the Service Worker. What's the actual use case for this? Well the original idea I had in mind was quite abstract in syncing state across all opened tabs for an application. Let me show you a basic example through the medium of the this suboptimal gif:

Since then I've had a deeper think and I believe there might be some more exact/substantial use cases for this technique to consider, especially in the web app space. For example you could sync the state of your application across tabs without the explicit need for polling/websockets, or watching localStorage / IndexDB. Think updating a balance after bank transfer on another tab, or close a EU cookies banner simultaneously across open clients. You could also do things like triggering tabs that are on a specific route to perform some action, like open a specific dialog or hide information that is no longer relevant. Kitson Kelly made the point that this could come into it's own in more heavy weight / power-user centered applications.

I'd be really opening to hearing other peoples suggestions on the matter, so feel free to drop me a line on Twitter. If you are interested in seeing the code you can check out the GitHub here.

Published