Activate updated service worker on refresh
When a service worker updates, it doesn't take control of the page right way; it goes into a "waiting" state, waiting to be activated.
Surprisingly, the updated service worker doesn't even take control of the tab after refreshing the page. Google explains:
https://developers.google.com/web/fundamentals/instant-and-offline/service-worker/lifecycle
Even if you only have one tab open to the demo, refreshing the page isn't enough to let the new version take over. This is due to how browser navigations work. When you navigate, the current page doesn't go away until the response headers have been received, and even then the current page may stay if the response has a
Content-Disposition
header. Because of this overlap, the current service worker is always controlling a client during a refresh.To get the update, close or navigate away from all tabs using the current service worker. Then, when you navigate to the demo again, you should see the horse [updated content].
This pattern is similar to how Chrome updates. Updates to Chrome download in the background, but don't apply until Chrome restarts. In the mean time, you can continue to use the current version without disruption. However, this is a pain during development, but DevTools has ways to make it easier, which I'll cover later in this article.
This behavior makes sense in the case of multiple tabs. My app is a bundle that needs to be updated atomically; we can't mix and match part of the old bundle and part of the new bundle. (In a native app, atomicity is automatic and guaranteed.)
But in the case of a single tab, which I'll call the "last open tab" of the app, this behavior is not what I want. I want refreshing the last open tab to update my service worker.
(It's hard to imagine that anybody actually wants the old service worker to continue running when the last open tab is refreshed. Google's "navigation overlap" argument sounds to me like a good excuse for an unfortunate bug.)
My app is normally only used in a single tab. In production, I want my users to be able to use the latest code just by refreshing the page, but that won't work: the old service worker will remain in control across refreshes.
I don't want to have to tell users, "to receive updates, be sure to close or navigate away from my app." I want to tell them, "just refresh."
How can I activate my updated service worker when the user refreshes the page?
EDIT: There's one answer I'm aware of that's quick, easy, and wrong: skipWaiting
in the service worker's install event. skipWaiting
will make the new service worker take effect as soon as the update downloads, while the old page tab is open. That makes the update unsafely nonatomic; it's like replacing a native app bundle while the app's running. That's not OK for me. I need to wait until the user refreshes the page of the last open tab.
Solution 1:
There are conceptually two kinds of updates, and it isn't clear from your question which we're talking about, so I'll cover both.
Updating content
Eg:
- The text of a blog post
- The schedule for an event
- A social media timeline
These will likely be stored in the cache API or IndexedDB. These stores live independently of the service worker - updating the service worker shouldn't delete them, and updating them shouldn't require an update to the service worker
Updating the service worker is the native equivalent of shipping a new binary, you shouldn't need to do that to (for example) update an article.
When you update these caches is entirely up to you, and they aren't updated without you updating them. I cover a number of patterns in the offline cookbook, but this is my favourite one:
- Serve page shell, css & js from a cache using the service worker.
- Populate page with content from the cache.
- Attempt to fetch content from the network.
- If the content is fresher than what you have in the cache, update the cache and the page.
In terms of "update the page", you need to do that in a way that isn't disruptive to the user. For chronological lists this is pretty easy, as you just add the new stuff to the bottom/top. If it's updating an article it's a bit trickier, as you don't want to switch text from underneath the user's eyes. In that case it's often easier to show some kind of notification like "Update available - show update" which, when clicked, refreshes the content.
Trained to thrill (perhaps the first ever service worker example) demonstrates how to update a timeline of data.
Updating the "app"
This is the case where you do want to update the service worker. This pattern is used when you're:
- Updating your page shell
- Updating JS and/or CSS
If you want the user to get this update in a non-atomic, but fairly safe way, there are patterns for this too.
- Detect updated service worker "waiting"
- Show notification to user "Update available - update now"
- When the user clicks this notification, postmessage to the service worker, asking it to call
skipWaiting
. - Detect the new service worker becoming "active"
window.location.reload()
Implementing this pattern is part of my service worker Udacity course, and here's the diff of the code before/after.
You can take this further too. For example, you could employ some kind of semver-esque system, so you know the version of the service worker the user currently has. If the update is minor you may decide calling skipWaiting()
is totally safe.
Solution 2:
I wrote a blog post explaining how to handle this. https://redfin.engineering/how-to-fix-the-refresh-button-when-using-service-workers-a8e27af6df68
I also have a working sample on github. https://github.com/dfabulich/service-worker-refresh-sample
There are four approaches:
-
skipWaiting()
on installation. This is dangerous, because your tab can get a mix of old and new content. -
skipWaiting()
on installation, and refresh all open tabs oncontrollerchange
Better, but your users may be surprised when the tab randomly refreshes. -
Refresh all open tabs on
controllerchange
; on installation, prompt the user toskipWaiting()
with an in-app "refresh" button. Even better, but the user has to use the in-app "refresh" button; the browser's refresh button still won't work. This is documented in detail in Google's Udacity course on Service Workers and the Workbox Advanced Guide, as well as themaster
branch of my sample on Github. -
Refresh the last tab if possible. During fetches in
navigate
mode, count open tabs ("clients"). If there's just one open tab, thenskipWaiting()
immediately and refresh the only tab by returning a blank response with aRefresh: 0
header. You can see a working example of this in therefresh-last-tab
branch of my sample on Github.
Putting it all together...
In the Service Worker:
addEventListener('message', messageEvent => {
if (messageEvent.data === 'skipWaiting') return skipWaiting();
});
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);
})());
});
In the page:
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);
}
// 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);
Solution 3:
In addition to Dan's answer;
1. Add the skipWaiting
event listener in the service worker script.
In my case, a CRA based application I use cra-append-sw to add this snippet to the service worker.
Note: if you are using a recent version of
react-scripts
, the snippet below is automatically added and you won't need to usecra-append-sw
.
self.addEventListener('message', event => {
if (!event.data) {
return;
}
if (event.data === 'SKIP_WAITING') {
self.skipWaiting();
}
});
2. Update register-service-worker.js
For me, it felt natural to reload the page when the user navigates within the site. In the register-service-worker script I've added the following code:
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.');
const pushState = window.history.pushState;
window.history.pushState = function () {
// make sure that the user lands on the "next" page
pushState.apply(window.history, arguments);
// makes the new service worker active
installingWorker.postMessage('SKIP_WAITING');
};
} else {
Essentially we hook into the native pushState
function so we know that the user is navigating to a new page. Although, if you also use filters in your URL for a products page, for example, you may want to prevent reloading the page and wait for a better opportunity.
Next, I'm also using Dan's snippet to reload all tabs when the service worker controller changes. This also will reload the active tab.
.catch(error => {
console.error('Error during service worker registration:', error);
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) {
return;
}
refreshing = true;
window.location.reload();
});