04 Jul 2018, 20:37

Service Worker State Management

With this post I want to build upon a previous technique I wrote about; message passing with Service Workers. Here I will be looking at how you might integrate this concept to manage application state across tabs for a site. For those of you unfamiliar, Service Workers are a separate thread that sit at in the background at browser level (rather than the page level like Web Workers) allowing pages and workers on the same domain scope (i.e. example.com) to interact with it. This makes them a great host for doing things such as intercepting and caching requests, handling offline requests, triaging push notifications and managing background syncing. This is even more true now that they are supported in all modern browsers!

Off the Beaten Track with Service Workers

As well as these more run-of-the-mill Service Worker patterns, there are also some more experimental ideas, like on the fly WebP support, or caching from a ZIP file. It’s definitely cool that Service Workers enable this interesting applications. Previously I wrote about passing messages between tabs using a Service Worker, inspired by some tweets and in turn a blog post by Craig Russell. I also recently realised Arnelle Balane wrote some similar ideas, albeit a slight different approach, which are worth a read.

In this post I want to take this further by exploring the idea of state management in a Service Worker. Although many of you might be versed in writing complex applications, I wanted to run through state management at high level before we kick off. For the TL;DR skip to the Mixing State Management with Service Workers section.

State Management

We can think of state management as the remits of how we organise and update the state of our application. For example if our web application was a simple counter, state management would entail concepts like:

  • What is it’s default value?
  • Where do we store it’s value?
  • How do we update the counter?
  • In which ways can the counter increment/decrement?

You can see how for even a arguably straightforward application such as a counter the cognitive load we have to endure as developers can quickly stack up. This is where state management libraries come in.

State management libraries are tools that allow you to centralise you state management, allowing updates to your data to become more predictable as they are funnelled through very specific and narrow channels. This in theory reduce bugs, decrease cognitive load, and in turn make it quicker and easier to scale a web application. With this being said, although state management can simplify scaling complex applications, they may actually make smaller applications more complicated than necessary (see this great post from Dan Abrimov for a deeper insight on that). Many state management libraries are based (or loosely based on the concepts of), Facebook’s Flux pattern. Of these Redux is perhaps the most popular. Another popular state management library is MobX which takes a more reactive/observer based approach to state management.

Mixing State Management with Service Workers

Now for the interesting part; putting state management in a Service Worker. Because Service Workers exist outside of a page and/or worker context for a given domain scope, we can use them to pass data to each other. So what if we took this a step further and stored the apps state in Service Worker? So I know what you’re potentially thinking, which is ‘is this a good idea?’ and in honesty I’m not even sure, but it’s definitely fun and foreseeably useful in specific cases.

Initially I tried to use Redux as the demonstration state manager, however I hit a hurdle. My proof-of-concept appeared to work great in Chrome, but in Firefox it would fail when changing between tabs. What was going on? As it currently stands (June 2018) it looks like Firefox kills off idle Service Workers after 30 seconds, although from my experimenting it seems actually less than that. This means when the tab is idle for a certain period, the script is re-executed when a new message is sent to the worker. State is wiped during this process, making it a none viable approach. There is some potential Chrome might be doing this in the future.

So, what to do? One suggestion in the above issue suggests is sending some sort of message on a timer to keep the Service Worker alive. I’m not a massive fan of this approach though as it feels a bit flakey and in general think timers should be avoided where possible. So what else can we do? Jeff Posnick recommends using IndexDB for persisting state, which got me looking into IndexedDB backed Redux libraries. I came across another Redux library called redux-persist. However this didn’t work out, as the state didn’t seem to persist the data in a way that was conducive to syncing state in the way I wanted. So instead, I rolled my own state library based on idb by Jake Archibald.

The Web Page

Let’s start with the web page first, let’s assume we are building our counter application and we have a standard HTML page. Next we’re going to want to register our Service Worker (let’s assume it’s wrapped in a ('serviceWorker' in navigator):


	navigator.serviceWorker.register('serviceworker.js')
		.then((reg) => {

			// Here we add the event listener for receiving messages
			navigator.serviceWorker.addEventListener('message', function(event){
				// Some function that renders the state to the page
				render(event.data.state.count);
			});

			messageServiceWorker({ GET_STATE: true});

		}).catch(function(error) {
			console.error('Service Worker registration error : ', error);
		});

	// When a new SW registration becomes available
	navigator.serviceWorker.oncontrollerchange = function() {
		messageServiceWorker({ GET_STATE: true});
	}

Here we are going to do something interesting; we’re going to tell the page that when it closes, we want to fire an event to the Service Worker letting it know that tab died. Because Service Workers can exist even when the page isn’t open, we need to a way to reset the state when no tabs are open. Again lets assume we use feature detection for the Service Worker:


	// Event on tab/window closed, so we can then check for no tabs/window.
	// If we wanted we could make this false to permanently persist state
	if (RESET_ON_NO_CLIENTS) {
		window.onunload = function() {
			// postMessage should be synchronous in this context?
			navigator.serviceWorker.controller.postMessage({
				TAB_KILLED: true
			});
		};
	}

We’ll also need a way to post our actions to our Service Worker so the Redux store and dispatch them, so lets add that:


	// Send generic messages to the Service Worker
	function messageServiceWorker(data){
		if (navigator.serviceWorker && navigator.serviceWorker.controller) {
			navigator.serviceWorker.controller.postMessage(data);
		}
	}

	// Pass actions specifically to the Service Worker
	function actionToServiceWorker(action) {
		messageServiceWorker({ ACTION: action })
	}

Let’s also say for the sake of simplicity that we only want to increment the counter, we could do it like this:


    document.getElementById('increment')
		.addEventListener('click', function () {
			actionToServiceWorker('INCREMENT');
		});

The Service Worker

A Service Worker exists as a single file, although may import others with the importScripts function. Let’s setup a Service Worker that can handle our state changes are persist them. Because Service Workers are only supported in modern browsers, I’ve written these in ES6 syntax. Firstly lets handle the incoming messages to the worker:


	initialiseOnMessage() {
		if (!self) {
			console.error("Self undefined, are you sure this is a worker context?");
			return;
		}
		self.onmessage = (message) => {
			if (message.data.GET_STATE) {
				this.store.getState().then((state) => {
					this.syncTabState(state);
				});
			} else if (message.data.TAB_KILLED) {
				this.checkIfAllTabsKilled(actions.RESET)
			} else if (message.data.ACTION) {
				this.dispatchToStore(message.data.ACTION)
			}
		}
	}

Next lets handle syncing state to the tabs. Here we need to be able to be able to dispatch events to our store, sync that store with new state, and also reset that store when all the tabs have been closed. Let’s see how we can do that:


		// Get all the tabs for the current domain scope
		getTabs() {
			return self.clients.claim().then(() => {
				return clients.matchAll(
					{
						includeUncontrolled: true,
						type: "window"
					}
				);
			})
		}


		// Dispatch a store event and sync that back to the tabs
		dispatchToStore(action, clientId) {
			this.store.dispatch(action).then(() => {
				this.store.getState().then((state) => {
					this.syncTabState(state);
				})
			})
		}

		// Check if all the tabs have died and if so reset the state
		checkIfAllTabsKilled(RESET) {

			this.getTabs().then((clients) => {

				// Sometimes the new client exists before we can check if there
				// are no tabs open at all. Essentially we need to handle the refresh case
				const isRefresh = clients.length === 1 && this.lastKnownNumClients < 2;
				const shouldReset = clients.length === 0 || isRefresh;

				if (shouldReset) {
					// Reset state back to normal
					this.store.dispatch(RESET);
				}

				this.lastKnownNumClients = clients.length;

			});

		}

		// Sync the state back to all the available tabs and windows
		syncTabState(newState) {

			this.getTabs().then((clients) => {
				// Loop over all available clients
				clients.forEach((client) => {
					const data = { state: newState }
					client.postMessage(data);
				});

				this.lastKnownNumClients = clients.length;

			});

		}

This code misses out the logic for actually updating our IndexedDB store, but under the hood it’s a mix of a Redux-esque pattern and the idb library I mentioned for persisting that store. The state will only update if the persistence part is successful. You can find the full code for the store logic in the GitHub link below.

Pulling it All Together

Now I’ve explained the page and Service Worker parts, let’s see how it looks in practice! You can find a link to a live demo here, and a link to the full code here.

Conclusion

Is it possible to put your state management in a Service Worker? Totally, if you’re willing to persist state. Is it sensible? I’m not entirely sure. Some obvious shortcomings are that you’ll have to write a fallback for none SW support browsers, you can’t use it in incognito in Firefox and it’s going to increase the complexity of the app with the message passing / asynchronosity aspect. Also in theory there’s more points of failure as you’re introducing a Service Worker and IndexedDB into the mix. This being said, if having tabs in sync is a critical part of your application, this may be a reasonable approach to solving that specific problem. Another way might be to just broadcast the actions to all other pages which, in theory should keep them in sync whilst keeping the Service Worker stateless.

07 Feb 2018, 19:00

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.