Flawless and Silent Upgrade of the Service Worker 🔌
Tags: webdev, javascript, serviceworker, update
Published: 2021-04-06 17:32:53

![Service Worker](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bo9tjehr8eyg580scprk.gif)

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](https://create-react-app.dev/docs/making-a-progressive-web-app/), 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`** 
```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`**
```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`**
```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`** 
```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()](https://developer.mozilla.org/en-US/docs/Web/API/Cache/add) and the [Cache.put()](https://developer.mozilla.org/en-US/docs/Web/API/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`**
```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`**
```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`** 
```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](https://developers.google.com/web/ilt/pwa/caching-files-with-service-worker#removing_outdated_caches):

#### **`service-worker.js`**
```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](https://github.com/a-tonchev/react-boilerplate/blob/master/src/service-worker.js)
[The Registration File](https://github.com/a-tonchev/react-boilerplate/blob/master/src/serviceWorkerRegistration.js)
[The SWHelper](https://github.com/a-tonchev/react-boilerplate/blob/master/src/components/helpers/SWHelper.js)

Best wishes,
ANTON TONCHEV
Co-Founder & Developer of [JUST-SELL.online](https://just-sell.online)