How to Fix the Refresh Button When Using Service Workers

I’m afraid you’ll have to learn the entire Service Worker API along the way.

Dan Fabulich
Code Red

--

Dan Fabulich is a Principal Engineer at Redfin. (We’re hiring!)

In a previous article, I explained how and why Service Workers break the browser’s Refresh button by default.

But nobody likes a whiner! In this article, I’ll document how to fix the Refresh button.

Getting this right requires an intimate understanding of the Service Worker lifecycle, the Caches API, the Registration API, and the Clients API. By the time you’ve plowed through this article, you’ll know pretty much everything you need to know about the Service Worker API.

Discuss on Hacker News
Discuss on Reddit

Step One: Read my previous article

My previous article on refreshing Service Workers provides a lot of valuable background material. I’ll try to summarize it in this section, but I think you’ll probably just have to read the whole thing first, because Service Workers are rocket science.

Here’s the key insight:

v1 tabs tightly couple to their v1 Cache; v2 tabs tightly couple to their v2 Cache. This tight coupling makes them “application caches.” The app must be completely shut down (all tabs closed) in order to upgrade atomically to the newest cache of code.

Service Workers are like apps. You can install them, start them, stop them, and upgrade them.

You can’t safely upgrade an app (or a Service Worker) while it’s still running; you need to shut the old version down first, ensuring what I call “code consistency.” In addition, since new versions of an app may change the schema of client-side data, it’s important to run only one version of the app at a time, ensuring “data consistency.”

Service Workers can install a bundle of files atomically with the install event, then run data migration during the activate event. By default, once a v1 Service Worker activates, the v2 version of the Service Worker can install but will refuse to activate, “waiting” until the old v1 Service Worker dies.

The old v1 Service Worker won’t die until all of the v1 Service Worker’s tabs have closed. (The Service Worker API calls tabs “clients.”)

While the v1 Service Worker lives, the Refresh button will generate pages from the old v1 Service Worker’s cache, no matter how many times we refresh.

Let’s fix this, shall we?

Four Ways to Solve the Same Problem

Here are four different approaches to fixing the Refresh button and forcing an update, each associated with a different stage of developer mindfulness and intellectual development.

Approach #1: We can just skip waiting (but beware)

“For every complex problem there is an answer that is clear, simple, and wrong.” — H. L. Mencken

The simplest and most dangerous approach is to just skip waiting during installation. There’s a one-line function in the global scope of all Service Workers, skipWaiting(), that will do this for us. When we use it, the new v2 Service Worker will immediately kill the old v1 activated Service Worker once the v2 Service Worker installs.

But beware: this approach eliminates any guarantees of code consistency or data consistency.

The new v2 Service Worker will activate and delete the old v1 Cache while a v1 tab is still open. What happens if the v1 tab tries to load /v1/styles.css at that point?

At best, the v1 tab will attempt to load our style sheet from the network, which is a waste of bandwidth, because we just discarded a perfectly good cached copy of the file. At worst, the user might be offline at the time, resulting in a broken v1 tab, but the user won’t know why.

The worst thing about blindly calling skipWaiting() is that it appears to work perfectly at first, but it results in bugs in production that are difficult to understand and reproduce.

Approach #2: Refresh old tabs when a new Service Worker is installed

This is just a slight tweak on the previous approach. navigator.serviceWorker is a ServiceWorkerContainer object; it has a controllerchange event which fires when a new Service Worker takes control of the current page.

So suppose we skipWaiting() during installation and add code like this in the web page:

navigator.serviceWorker.addEventListener('controllerchange',
function() { window.location.reload(); }
);

That does work. When the v2 Service Worker skips waiting to take control, any v1 tab will automatically refresh, turning it into a v2 tab.

But it’s not a good user experience to trigger a refresh when the user’s not expecting it. If the page refreshes while the user is filling out an order form for styptic pencils, you could lose data, money, and/or blood. 💔

UPDATE: If you use this technique, use a variable to make sure you refresh the page only once, or you’ll cause an infinite refresh loop when using the Chrome Dev Tools “Update on Reload” feature.

var refreshing;
navigator.serviceWorker.addEventListener('controllerchange',
function() {
if (refreshing) return;
refreshing = true;
window.location.reload();
}
);

Approach #3: Allow the user to control when to skip waiting with the Registration API

In this approach, we’ll wait for a new Service Worker to install, and then we’ll pop up a UI message in the page, prompting the user to click an in-app “Refresh” link.

We’ll still configure the page to refresh when the controllerchange event fires, as in Approach #2 — but in this approach, the event will fire when the user asks for a refresh, so we know the user won’t be interrupted.

Oh, joy, another Service Worker API: Registration API

To listen for a new Service Worker, we’ll need to use a ServiceWorkerRegistration object. In our “naive” attempt to hook up a Service Worker, we called navigator.serviceWorker.register() to register our Service Worker, but we didn’t do anything with the returned value. It turns out that register() returns a Promise for a “registration.”

There are a bunch of nifty things we can do with a registration, plus a handful of useful things we can do with the navigator.serviceWorker ServiceWorkerContainer object that I haven’t mentioned yet.

A few things you can do with a registration:

  • Call update() on the registration to manually refresh the Service Worker script. The Service Worker script automatically attempts to refresh when we call navigator.serviceWorker.register(), but we might want to poll for updates more often than that, e.g. once an hour or something.
  • Call unregister() on the registration to terminate and unregister the Service Worker.
  • Call navigator.serviceWorker.getRegistration() to get a Promise for the existing registration (which may be null).
  • Get a reference to the active ServiceWorker object (the ServiceWorker object that controls the current page) via the navigator.serviceWorker.controller property or the registration’s .active property.
  • We can also use a registration to get a reference to a .waiting Service Worker or an .installing Service Worker.
  • Once we have a ServiceWorker, we can call postMessage() to send data to the Service Worker. Inside the Service Worker script, we can listen for a message MessageEvent, which has a .data property containing the message object.

Finally, the actual implementation

We start by adding code to the Service Worker’s script to listen for a posted message:

addEventListener('message', messageEvent => {
if (messageEvent.data === 'skipWaiting') return skipWaiting();
});

Next, we’ll need to listen for a .waiting ServiceWorker, which requires some inconvenient boilerplate code. The registration fires an updatefound event when there’s a new .installing ServiceWorker; the .installing ServiceWorker fires a statechange event once it’s in the “installed” waiting state.

Here’s the boilerplate. Note that since this code runs in the web page, and not in the Service Worker script, we’ve written it in ES5 JavaScript.

function listenForWaitingServiceWorker(reg, callback) {
function awaitStateChange() {
reg.installing.addEventListener('statechange', function() {
if (this.state === 'installed') callback(reg);
});
}
if (!reg) return;
if (reg.waiting) return callback(reg);
if (reg.installing) awaitStateChange();
reg.addEventListener('updatefound', awaitStateChange);
}

We’ll listen for a waiting Service Worker, then prompt the user to refresh. When the user requests a refresh, we’ll post a message to the waiting Service Worker, asking it to skipWaiting(), which will fire the controllerchange event, which we’ve configured to refresh the page.

// reload once when the new Service Worker starts activating
var refreshing;
navigator.serviceWorker.addEventListener('controllerchange',
function() {
if (refreshing) return;
refreshing = true;
window.location.reload();
}
);
function promptUserToRefresh(reg) {
// this is just an example
// don't use window.confirm in real life; it's terrible
if (window.confirm("New version available! OK to refresh?")) {
reg.waiting.postMessage('skipWaiting');
}
}
listenForWaitingServiceWorker(reg, promptUserToRefresh);

Note that this Approach #3 works even if there are multiple open tabs. All of the tabs will display the “update available” prompt; when the user clicks the “refresh” link in any one of them, the new Service Worker will take control of all of the tabs, causing all of them to refresh to the new version.

However, Approach #3 has two major drawbacks.

First, this approach is hella complicated. Maybe you can copy and paste this boilerplate, but you won’t be able to debug it unless you truly grok it, which requires an intimate understanding of the Registration API, the Service Worker life cycle, and how to test for the problems resulting from code/data inconsistency.

Despite the complexity, Approach #3 seems to be the standard recommended approach. Google has put together a Udacity course on Service Workers, and this is the approach that they document. (It occupies a significant fraction of the course, and includes three sub-quizzes building on one another. 😭)

I wish this approach were easier; there are a couple of tickets on the Github repository where these things are managed. Specifically, I wish it were easier to listen for a waiting Service Worker, and I wish it were possible to make a Service Worker skip waiting without posting a message to it.

Imagine if this whole section were replaced by a snippet like this:

function promptUserToRefresh() {
// don't use confirm in production; this is just an example
if (confirm('Refresh now?')) reg.waiting.skipWaiting();
}
if (reg.waiting) promptUserToRefresh();
reg.addEventListener('statechange', function(e) {
if (e.target.state === 'installed') {
promptUserToRefresh();
} else if (e.target.state === 'activated') {
window.location.reload();
}
});

Second, this approach does not and cannot fix the browser’s built-in Refresh button. To refresh the page, users have to use an in-app Refresh link instead.

When the user actually has multiple tabs open, that might be for the best. We need the user’s browser to refresh all of our app’s tabs at once in order to restart the “app” and ensure data consistency. But when there’s just one tab remaining, we can do better.

Approach #4: Skip waiting when the last tab refreshes with the Clients API (buggy in Firefox)

The Request object has a .mode property. It’s mostly used for managing the security restrictions around the same-origin policy and Cross-Origin Resource Sharing (CORS) with settings like: same-origin, cors, and no-cors. But it has one more mode that’s not like the others: navigate.

When a Service Worker’s fetch listener sees a request in navigate mode, then we know that a new document is being loaded. At that point, we can count the currently running tabs (“clients”) with the Clients API.

Oh yes, my friends, it’s time to learn yet another new API.

Using the global clients Clients object, we can get a list of Client objects. Each Client has an id property, corresponding to each FetchEvent’s .clientId property.

But we don’t really need to do anything with the individual clients or their IDs; we just need to count them. From there, we can use the registration object in the global scope of the Service Worker to see if there’s a Service Worker waiting, and post it a message asking it to skip waiting in that case.

if (event.request.mode === 'navigate' && registration.waiting) {
if ((await clients.matchAll()).length < 2) {
registration.waiting.postMessage("skipWaiting");
}
}

There are two problems with this snippet. First, as of November 2017, this technique works great in Chrome 62, but it doesn’t work in Firefox. registration.waiting is always null from inside the Service Worker. Hopefully the Firefox team will sort this out soon.

There’s an another, deeper problem. The navigation has already started, and the old v1 Service Worker is already handling it. If the v1 Service Worker handles the request in the normal way with caches.match(), it will respond to the navigation request with a response from the old v1 Service Worker’s cache (because caches.match() always prefers to match from the oldest available cache), but the refreshed page will then try to load all of its scripts and styles from the new v2 cache, blatantly violating code consistency.

In this case, we’ll return a blank response that instantly refreshes the page, instead. We’ll use the HTTP Refresh header to refresh the page after 0 seconds; the page will appear blank for an instant, then refresh itself as a v2 tab.

addEventListener('fetch', event => {
event.respondWith((async () => {
if (event.request.mode === "navigate" &&
event.request.method === "GET" &&
registration.waiting &&
(await clients.matchAll()).length < 2
) {
registration.waiting.postMessage('skipWaiting');
return new Response("", {headers: {"Refresh": "0"}});
}
return await caches.match(event.request) ||
fetch(event.request);
})());
});

With this code in place, the browser’s Refresh button does what it’s “supposed” to do when we’re using just one tab.

I recommend combining this technique with the Approach #3 in-app “refresh” link described earlier, to handle the case where multiple tabs need to be updated, and to handle Firefox’s buggy implementation of registration.waiting.

Whoa! You Know Service Workers Now!

Typical Service Worker tutorials like the “Using Service Workers” guide on MDN introduce only the raw basics (as of November 2017): a simple install listener that caches a list of URLs in a versioned Cache, a fetch listener, and an activate listener to expire obsolete Caches.

But as you’ve read this article and the previous article, you’ve learned about almost all of the other classes in the ServiceWorker API:

  • Cache
  • CacheStorage (caches)
  • Client
  • Clients (clients)
  • ExtendableEvent
  • FetchEvent
  • InstallEvent
  • Navigator.serviceWorker (the ServiceWorkerContainer)
  • ServiceWorker (especially its postMessage() method)
  • ServiceWorkerGlobalScope
  • ServiceWorkerRegistration

As of 2017, there are other features associated with Service Workers (Background Sync and Push Notifications), but those APIs are a piece of cake compared to the nightmare you’ve been through to fix the Refresh button.

So, congratulations on getting through all of this material! You’re pretty much a Service Worker wizard at this point.

Discuss on Hacker News
Discuss on Reddit

source: Pop Team Epic (also known as Poptepipic) (ポプテピピック, Poputepipikku)

P.S. Redfin is hiring.

--

--