As many of you already know, the upgrading of the service worker gives us agony. Until now we always need to make some compromises. But what if I tell you, after a lot of struggling I figured out a way to update the service worker flawlessly?
- No direct skipWaiting (which would break still running code, especially when code-splitting)
- No confusing window.location.reload that makes bad user experience
- No annoying pop-up window to tell the user to close all tabs and to refresh the page
- No self-destroying service worker, no need to iterate around clients.
While accomplishing a flawless service worker update, we can solve these following problems:
β Need of all tabs to be closed, because old service worker is still in use by other tabs/windows
β Need of window.location.reload to get new service worker
β Need of User Interaction to update
β If the Service worker updates not fast enough, old HTML or old resources may still be present even after reload, so we would need βagain to force a reload
This Article is based on Create React APP (CRA) that has been generated with the cra-tempate-pwa, but the principle is of course the same for any Web App.
Okay, letβs start!
Step 1: Identify if new service worker is available
These can happen in 2 cases:
New service worker is being found and just installed
New service worker has already been installed, and now it is in the waiting state
Letβs use a global variable window.swNeedUpdate to know if there is a waiting service worker that needs installation. We can do this in our service worker registration (in CRA this would be the function registerValidSW
of src/serviceWorkerRegistration.js
):
- Set window.swNeedUpdate = true; in the installingWorker.onstatechange event
- Set window.swNeedUpdate = true; if registration in a waiting state has been detected
serviceWorkerRegistration.js
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
if (registration.waiting && registration.active) {
// WE SET OUR VARIABLE HERE
window.swNeedUpdate = true;
}
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// WE SET OUR VARIABLE ALSO HERE
window.swNeedUpdate = true;
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
//...
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
//...
}
Step 2: Prepare cache storage name
The next thing we need is to make clear difference between the new and old cache storage.
In our service-worker.js
(CRA: src/service-worker.js
) we will use our own unique string, adding it into the cache name of the service worker. Here I am using a variable called REACT_APP_VERSION_UNIQUE_STRING from my .env file, but you can have any unique string you want, even static one. Just keep in mind that this variable should be unique and long, so that there are no mixed-up results when we search for it. And NEVER forget to change it when generating every new service worker!!!
βWe can setup our unique string and make use of the workbox-core
setCacheNameDetails
function:
service-worker.js
import { setCacheNameDetails .... } from 'workbox-core';
const CACHE_VARIABLE = process.env.REACT_APP_VERSION_UNIQUE_STRING;
setCacheNameDetails({
prefix: 'my-project',
suffix: CACHE_VARIABLE,
});
Step 3: Create own skipWaiting, which will work only if one client (tab/window) is available
It is not possible to get the number of all open tabs easily in JavaScript, but fortunately, the service worker knows how many clients it serves!
So, in the message-event listener we can create our own condition, letβs call it 'SKIP_WAITING_WHEN_SOLO':
service-worker.js
self.addEventListener('message', (event) => {
// Regular skip waiting
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
// Our special skip waiting function!
if (event.data && event.data.type === 'SKIP_WAITING_WHEN_SOLO') {
self.clients.matchAll({
includeUncontrolled: true,
}).then(clients => {
if (clients.length < 2) {
self.skipWaiting();
}
});
}
});
As you can see, when we send our SKIP_WAITING_WHEN_SOLO
event, the skipWaiting method will be called only if there is 1 (or less) open clients!
When we look again at the problems above, we already solved the first one:
β
Need of all tabs to be closed, because old service worker is still in use by other tabs/windows
β Need of window.location.reload to get new service worker
β Need of User Interaction to update
β If the Service worker updates not fast enough, old HTML or old resources may still be present even after reload, so we would need again to force a reload
Now when we have identified waiting service worker and when all tabs are closed, the next thing we need to do is to fire the skipWaiting SKIP_WAITING_WHEN_SOLO
event on the right place.
Step 4: Send skipWaiting event when page get closed
What would be better place to fire the event than when page is closed or reloaded? In our serviceWorkerRegistration.js
we add the beforeunload
event, where we put our skipWaiting under the condition that new service worker is waiting to be installed:
serviceWorkerRegistration.js
const SWHelper = {
async getWaitingWorker() {
const registrations = await navigator?.serviceWorker?.getRegistrations() || [];
const registrationWithWaiting = registrations.find(reg => reg.waiting);
return registrationWithWaiting?.waiting;
},
async skipWaiting() {
return (await SWHelper.getWaitingWorker())?.postMessage({ type: 'SKIP_WAITING_WHEN_SOLO' });
},
};
window.addEventListener('beforeunload', async () => {
if (window.swNeedUpdate) {
await SWHelper.skipWaiting();
}
});
// ...
}
To keep my code cleaner I used helpers like β SWHelper.
Now we also solved the next 2 problems:
β
Need of all tabs to be closed, because old service worker is still in use by other tabs/windows
β
Need of window.location.reload to get new service worker
β
Need of User Interaction to update
β If the Service worker updates not fast enough, old HTML or old resources may still be present even after reload, so we would need again to force a reload
Okay, now if we close the Browser and open it again, we are all done. But there is only one problem β when we have waiting SW, and we have only 1 tab open, and we reload the tab, the service worker will get activated, but in the fast reload the old SW may still deliver us its old HTML which will cause fetch errors, since the old resources are no more available!
Step 5: Replace the cache response of the index.html request in the old service workerβs cache storage with the most-recent index.html
To reach this, we fully make use of the Cache.add() and the Cache.put() methods of the SW Cache API.
Now we will create the most important functionality of our Project. This Functions, simple said, copy all the content of index.html from our new service worker into our old service worker, and replace it. Isnβt it cool?
service-worker.js
const getCacheStorageNames = async () => {
const cacheNames = await caches.keys() || [];
let latestCacheName;
const outdatedCacheNames = [];
for (const cacheName of cacheNames) {
if (cacheName.includes(CACHE_VARIABLE)) {
latestCacheName = cacheName;
} else if (cacheName !== 'images') {
outdatedCacheNames.push(cacheName);
}
}
return { latestCacheName, outdatedCacheNames };
};
const prepareCachesForUpdate = async () => {
const { latestCacheName, outdatedCacheNames } = await getCacheStorageNames();
if (!latestCacheName || !outdatedCacheNames?.length) return null;
const latestCache = await caches?.open(latestCacheName);
const latestCacheKeys = (await latestCache?.keys())?.map(c => c.url) || [];
const latestCacheMainKey = latestCacheKeys?.find(url => url.includes('/index.html'));
const latestCacheMainKeyResponse = latestCacheMainKey ? await latestCache.match(latestCacheMainKey) : null;
const latestCacheOtherKeys = latestCacheKeys.filter(url => url !== latestCacheMainKey) || [];
const cachePromises = outdatedCacheNames.map(cacheName => {
const getCacheDone = async () => {
const cache = await caches?.open(cacheName);
const cacheKeys = (await cache?.keys())?.map(c => c.url) || [];
const cacheMainKey = cacheKeys?.find(url => url.includes('/index.html'));
if (cacheMainKey && latestCacheMainKeyResponse) {
await cache.put(cacheMainKey, latestCacheMainKeyResponse.clone());
}
return Promise.all(
latestCacheOtherKeys
.filter(key => !cacheKeys.includes(key))
.map(url => cache.add(url).catch(r => console.error(r))),
);
};
return getCacheDone();
});
return Promise.all(cachePromises);
};
Here I exclude βimagesβ from the cache names and I also copy all the requests and their responses into the old service worker to cover some very rare theoretical possible edge cases (e.g. If the user has multiple tabs open with waiting service worker, installs from some of it the PWA, and goes immediately offline etc...)
The best place to call this functionality would be again in the βmessageβ-event listener of the service worker, so we add there our newly created case:
service-worker.js
self.addEventListener('message', (event) => {
// Regular skip waiting
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
// Our special skip waiting function!
if (event.data && event.data.type === 'SKIP_WAITING_WHEN_SOLO') {
self.clients.matchAll({
includeUncontrolled: true,
}).then(clients => {
if (clients.length < 2) {
self.skipWaiting();
}
});
}
// HERE COMES OUR NEWLY CREATED FUNCTION
if (event.data && event.data.type === 'PREPARE_CACHES_FOR_UPDATE') {
prepareCachesForUpdate().then();
}
});
And the only thing left is to call this event, when we have installation of new service worker:
serviceWorkerRegistration.js
const SWHelper = {
async getWaitingWorker() {
const registrations = await navigator?.serviceWorker?.getRegistrations() || [];
const registrationWithWaiting = registrations.find(reg => reg.waiting);
return registrationWithWaiting?.waiting;
},
async skipWaiting() {
return (await SWHelper.getWaitingWorker())?.postMessage({ type: 'SKIP_WAITING_WHEN_SOLO' });
},
// Method to call our newly created EVENT:
async prepareCachesForUpdate() {
return (await SWHelper.getWaitingWorker())?.postMessage({ type: 'PREPARE_CACHES_FOR_UPDATE' });
},
};
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
if (registration.waiting && registration.active) {
window.swNeedUpdate = true;
}
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
window.swNeedUpdate = true;
// WE FIRE THE EVENT HERE:
SWHelper.prepareCachesForUpdate().then();
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
//...
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
//...
One more thing β when the new service worker get activated, we surely donβt need any more the old cache. To clean it up we simply follow this documentation:
service-worker.js
self.addEventListener('activate', event => {
event.waitUntil(
getCacheStorageNames()
.then(
({ outdatedCacheNames }) => outdatedCacheNames.map(cacheName => caches.delete(cacheName)),
),
);
});
Well thatβs it, we covered all the cases, we solved all the problems, and we have a flawless service worker update. Now, when the user has a service worker the following will happen:
β‘ When the user refresh/close the page and there are no other tabs handled by the same service worker, or when the user closes all the browser, the new service worker will be activated. And this will happen for sure sooner or later.
β‘ If there are other open tabs, on refreshing one of them, the user will already see the new content, EVEN BEFORE the new service worker is activated.
β‘ The user will experience no popup, no reload and no errors while operating the App
Isnβt it great?
You can see an whole example project here:
https://github.com/a-tonchev/react-boilerplate
The Service Worker
The Registration File
The SWHelper
Best wishes,
ANTON TONCHEV
Co-Founder & Developer of JUST-SELL.online
Top comments (11)
Hey, I have a quick question regarding this bit:
Should the
REACT_APP_VERSION_UNIQUE_STRING
only change when making changes to the service worker, or upon every build and deploy?It should be on every build and deploy. Since on every build and deploy you have new files. But you can make it automated - add Date String prefix, suffix etc...
Thank you!
If anyone is interested, I've edited this script found on SO to replace my
"build": "craco build"
command inpackage.json
:I have just upgraded from react 16 to 17 and then followed this tutorial, but I still need to close the tab and open again to see the updates, I really hate PWA's.
Hi @myfairshare the update does not happen immediately. First you need to load the page, so that it detects the new service worker, to prepare caches and to replace the index.html
After the next reload your website will be updated with the new service worker
Ok thanks for the reply mate. I think next time I build a mobile app I will try flutter instead!!
Unfortunately there is a big difference and use cases between PWA and Mobile App. And PWA gain much on popularity. I also hate the way the PWA handles the updates, but they will surely change it in future.
I hope your right, thanks!!
Thanks Anton, going to try this, certainly is painful dealing with service workers.
If I understand correctly, the only time a new service worker will be activated immediately is when there is only one tab open, correct?
Yes