DEV Community

a-tonchev
a-tonchev

Posted on

Flawless and Silent Upgrade of the Service Worker πŸ”Œ

Service Worker

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:

  1. New service worker is being found and just installed

  2. 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):

  1. Set window.swNeedUpdate = true; in the installingWorker.onstatechange event
  2. 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);
              }
            }
          }
        };
      };
    })
  //...
}
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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();
      }
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

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();
  }
});
// ...
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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();
  }

});
Enter fullscreen mode Exit fullscreen mode

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);
              }
            }
          }
        };
      };
    })
  //...
Enter fullscreen mode Exit fullscreen mode

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)),
      ),
  );
});
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
brydom profile image
Brydon McCluskey

Hey, I have a quick question regarding this bit:

And NEVER forget to change it when generating every new service worker

Should the REACT_APP_VERSION_UNIQUE_STRING only change when making changes to the service worker, or upon every build and deploy?

Collapse
 
atonchev profile image
a-tonchev

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...

Collapse
 
brydom profile image
Brydon McCluskey • Edited

Thank you!

If anyone is interested, I've edited this script found on SO to replace my "build": "craco build" command in package.json:

const execSync = require("child_process").execSync;

const env = Object.create(process.env);
const generateId = () => Math.random().toString(36).substr(2, 9);

env.REACT_APP_VERSION_UNIQUE_STRING = `${generateId()}-${generateId()}-${generateId()}`;

console.log("Used env variables: " + JSON.stringify(env));
console.log("Run command: 'craco build'");

execSync("craco build", { env: env, stdio: "inherit" });
Enter fullscreen mode Exit fullscreen mode
Collapse
 
asdev808 profile image
asdev808

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.

Collapse
 
atonchev profile image
a-tonchev

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

Collapse
 
asdev808 profile image
asdev808

Ok thanks for the reply mate. I think next time I build a mobile app I will try flutter instead!!

Thread Thread
 
atonchev profile image
a-tonchev

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.

Thread Thread
 
asdev808 profile image
asdev808

I hope your right, thanks!!

Collapse
 
asdev808 profile image
asdev808

Thanks Anton, going to try this, certainly is painful dealing with service workers.

Collapse
 
dannymoerkerke profile image
Danny Moerkerke

If I understand correctly, the only time a new service worker will be activated immediately is when there is only one tab open, correct?

Collapse
 
atonchev profile image
a-tonchev

Yes