Photo by GK Hart/Vikki Hart/Getty Images

Best Practices for Using Notifications and Service Workers on a News Site

Tips based on our initial experiments for the UK election

At the Guardian Mobile Innovation Lab, we recently ran an experiment using the web Notification API to send the results of the UK election to directly to users’ lock screens. It was much like an experiment we ran for the Brexit vote last year, except for two big differences: it used the Notification API’s “image” attribute (implemented on Chrome as of version 56) to render a data visualisation in the notification, and all of the code ran on www.theguardian.com, rather than the lab’s domain.

We’ll get into the detail of the image part of the experiment later, but first, some notes on what it means to run a project like this on a domain you share with many other developers.

Why does it matter?

First, a brief explanation of how many large news organizations structure their tech teams.

There are typically two key teams for web-facing projects. In some places they’re called the “product” team and the “interactive” team, in others it’s “core” and “projects.” The simplest way to boil down the difference between the two is that one team focuses on long-term improvements and infrastructure and the other focuses on short-term projects. The long-term team is responsible for the web site as a whole, implementing new permanent functionality and improving existing features like article readers, slideshows and video players. The short-term team tends to be a lot more reactive: they’ll spin up a project to cover elections, the Olympics or breaking news events, and then spin them back down again when the event is over.

Neither team is ignorant of what the other does, but they’re also typically not involved in each other’s day-to-day work. Instead, the long-term team will set up a web framework that the short-term team can inject their content into. Sometimes that’s as simple as using an iframe. At other times it involves async Javascript requires and shared libraries. The intention is simple: to let the short-term team publish at will without hassling the long-term team, and to ensure the short-term team’s work doesn’t break the long-term team’s code.

But in order to use the Push and Notification APIs, we need to use the Service Worker API, and all these APIs break this contract in one way or another.

First up: the Notification API

Before you can send a notification, you must ask the user for permission to do so. And you have to ask on behalf of the entire domain. Worse, if the user taps “deny”, neither you nor anyone else publishing on your domain will be able to ask the user again, unless you can get them to reset their site permissions. So, if you’re building from within a short-term team here’s best practice number one:

Be very, very, very explicit about what a “sign up for notifications” button does before a user clicks on it. Never ask for notification permission on page load (but you’d never do that anyway, right?).

But that’s not all. If a user gets sick of the notifications you send them, their easiest solution is to long press on the notification and choose “block”, or else hit the “settings” button Chrome adds to each notification and revoke notification permissions. Again, this will apply domain-wide. So, best practice number two:

Be sure to provide a clear opt-out button on your notification. That way you can unsubscribe the user yourself, and preserve notification permissions.

It’s a shame, because you only have two action buttons to play with on mobile devices. But such is life.

The Service Worker API

…is confusing. Very powerful, but confusing. Let’s break it out into a few different sub-categories:

Scope

Every service worker is registered to a specific scope, or root URL. It means that any URLs within that scope are handled by the service worker. For instance, if you register a worker with the scope of:

https://www.example.com/election-2017

It would be the service worker for this page:

https://www.example.com/election-2017/results.html

But not this one:

https://www.example.com/sports/latest.html

This is actually fantastic news: we can create separate service workers for each project, and make sure they only cover URLs our project is using. But there is one issue: each page can only have one worker. For example, the Guardian has its own service worker at the root of the site, and the site’s core JS (which is included in the template the election results page uses) sends messages to it. I don’t know what those messages are, but the larger point is that I shouldn’t have to know — the long-term team should be able to do whatever they want with that code. But here’s the problem: once I register my election notifications worker it takes over all service worker operation, including intercepting fetches, from the site-wide worker.

There are two answers to this. One long-term solution, and one stop-gap-aah-the-election-is-in-four-days solution. Long term:

Put all of your universal JS in a separate JS file. Then use importScripts() to add it to your site-wide worker *and* to each of the project-specific workers you create.

There’s a big problem with this, though. When the browser checks for updates to your service worker, it only checks the worker JS file. So, if you change universal.js but don’t change worker.js, the browser will never notice the change. Easy to change the site-wide worker file (adding a hash or something every time universal.js is changed) but the project-specific workers will be out of date. Thankfully, this behaviour is changing, and soon browsers will check both the worker JS file and imported scripts.

Or, use the stop-gap solution:

Register your project-specific worker with a scope of its own URL, and then grab a reference to it manually.

This is the ultimate unobtrusive service worker solution. If you call:

navigator.serviceWorker.register(‘./worker.js’,{scope:’./worker.js’});

The browser will register the worker, but it won’t replace any site-wide workers. Then, rather than using navigator.serviceWorker.ready to get a reference to the worker, use navigator.serviceWorker.getRegistrations() to get an array of all the registrations on your domain, and pick the appropriate one. The downside: you can’t use this worker to intercept fetch events, so you can’t make full use of the Cache API. But it’s especially useful (if not vital) when making things like embedded interactives, which can appear on any page on the site.

The Cache and IndexedDB APIs

You can’t guarantee the lifecycle of a service worker (the browser can shut an instance down whenever it feels like it), so you need to store any data you are using in IndexedDB. And sometimes you want to cache assets you’re going to use offline via the Cache API. These work fine with multiple workers, but, just like the Notification API, they are domain-specific, not worker-specific. So:

Always namespace your cache names and IndexedDB stores. Then you’ll know another worker will not overwrite your data.

Side note: one bonus of caches being at a domain level — if you use caches.match() in your fetch event, it will check every cache that exists on the domain. So it’s possible for a project-specific worker to add a cached asset and have the site-wide worker return it.

Push registrations

Unlike the Cache and IndexedDB APIs, push registrations (the URL and key combo you need in order to send web notifications) isn’t domain specific, nor is it worker specific. It is worker *registration* specific. The worker and registration usually have a 1:1 relationship, but workers can be unregistered and reregistered, leaving you with IndexedDB data that no longer applies to your push registration. So:

If you’re recording push-specific data in IndexedDB (like, say, topics a user is subscribed to), be sure to cache the push registration key along with that data. Check whether the cached key matches your current one on startup, and wipe the data if it does not.

Looking Ahead

So, this is my best attempt at best practices for now. But there is one area I haven’t been able to resolve fully: cleaning up. When a time-sensitive project is complete, we will want the worker for it to clean up after itself — delete IndexedDB data, clear caches etc. — to make space available for future projects. If a user never visits a page inside the worker scope (and never receives another push message) the worker won’t be activated again to trigger any cleanup code.

What improvements could be added by the browser teams at Google and Mozilla to make this a lot easier for us?

Expose navigator.serviceWorker inside workers themselves

At the moment, web pages have access to navigator.serviceWorker to register workers, query what registrations currently exist, unregister workers… but workers themselves do not — they live in isolation, unaware of other workers around them. With access to other workers, a site-wide worker could be responsible for cleaning up project-specific workers. In fact, we could even route worker registration requests through the site-wide worker and attach an expiry date.

It appears that the spec supports this, but no browser manufacturers have implemented it. Yet!

Add an ‘unregister’ event to the service worker lifecycle

Although this would a little redundant if workers could communicate with each other (they could just postMessage a cleanup request), it would still be worthwhile catch-all functionality, no matter when or why the worker is being unregistered. In this event we could clear up the IndexedDB/Cache storage being used by the worker, before it is fully shut down.

There will be more

This is just a brief collection of thoughts based on our first experiment running notifications on theguardian.com. I’m sure there are plenty of other issues we haven’t run into yet. If you’ve been experimenting in this area and have run into issues of your own, please let us know. We’re always interested to learn where progress is being made elsewhere.


The Guardian Mobile Innovation Lab operates with the generous support of the John S. and James L. Knight Foundation.