Self unregistering service workers

thepassle

Pascal Schilp

Posted on March 25, 2024

Self unregistering service workers

The Problem

At work, we use microfrontends for our frontend features. These features get deployed to a CDN, and the url for those features look something like this:

https://cdn.ing.com/ing-web/2.44.0/es-modules/button.js
Enter fullscreen mode Exit fullscreen mode

The way this works is that features have full autonomy of their project and their dependencies when they are deployed. Imports to dependencies in a features source code get rewritten to a versioned URL of the dependency. Consider the following example:

feature-a@1.0.0
├─ ing-web@2.44.0
├─ ing-lib-ow@4.0.0
Enter fullscreen mode Exit fullscreen mode

Feature-a is the feature teams project, and it has two dependencies: ing-web@2.44.0 and ing-lib-ow@4.0.0. At buildtime, the imports for those dependencies will get rewritten so that they'll get loaded from:

https://cdn.ing.com/ing-web/2.44.0/es-modules/index.js
https://cdn.ing.com/ing-lib-ow/4.0.0/es-modules/index.js
Enter fullscreen mode Exit fullscreen mode

This is nice, because feature-a is totally in control of their project and dependencies. However, this leads to a problem when features come together in apps. If we have many different features, feature-a, feature-b, feature-c, and they all depend on a different version of ing-web:

feature-a@1.0.0
├─ ing-web@2.40.0
├─ ing-lib-ow@4.0.0

feature-b@1.0.0
├─ ing-web@2.41.0

feature-c@1.0.0
├─ ing-web@2.41.1
Enter fullscreen mode Exit fullscreen mode

The problem here is that the user visiting the app will download ing-web three times: version 2.40.0, version 2.41.0 and version 2.41.1. You can probably see why this is an issue; This is terrible for performance.

A potential solution

To combat this, I was tinkering with a service worker that simply rewrites the URL whenever a request is done to the CDN. Given the following request:

https://cdn.ing.com/ing-web/2.41.0/es-modules/button.js
Enter fullscreen mode Exit fullscreen mode

It will get rewritten to:

https://cdn.ing.com/ing-web/2.44.0/es-modules/button.js
Enter fullscreen mode Exit fullscreen mode

Given that we use semver, we can expect there not to be breaking changes in the entrypoints of those projects. This does however mean that teams lose a bit of autonomy, as common, shared dependencies (like the design system) will now get deduped on the application level, and the application dictates which version of the design system is supported. I think this is a fair trade-off to make, given the performance implications.

The service worker I implemented only does one thing;

  • If a CDN request comes in
    • If that request is for one of the packages we want to dedupe
    • Rewrite the request url to the version dictated by the app

Pretty straightforward. Here's some code:

const packages = {
  'ing-web': {
    '2': '2.44.0',
  },
};

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  if (url.host.includes('cdn.ing.com')) {
    const [pkg, requestedVersion, _, file] = url.pathname.split('/').filter(Boolean);
    const [requestedMajor] = requestedVersion.split('.');

    for (const [packageName, versions] of Object.entries(packages)) {
      if (packageName === pkg) {
        for (const [major, rewriteTo] of Object.entries(versions)) {
          if (major === requestedMajor && requestedVersion !== rewriteTo) {
            url.pathname = url.pathname.replace(requestedVersion, rewriteTo);
            return event.respondWith(fetch(url));
          }
        }
      }
    }
  }
});

self.addEventListener('install', () => {
  self.skipWaiting();
});

self.addEventListener('activate', event => {
  event.waitUntil(clients.claim());
});

Enter fullscreen mode Exit fullscreen mode

And in the index.html of our app, we register the service worker:

<script>
  navigator.serviceWorker.register('./sw.js');
</script>
Enter fullscreen mode Exit fullscreen mode

So what are you getting at?

So far so good. So what's actually the problem here? Well, my colleague Kristjan Oddsson mentioned this thing to me once:

It's always good to have an exit strategy for any code or library that you add

And so I was considering the impact of the service worker. I've used service workers quite a bit and I'd say I'm fairly well-versed with them, but in a large production application it can be a bit scary. What if something goes wrong? What if the service worker is buggy? If we ever want to get rid of the service worker, what is our exit strategy?

Doing some homework (googling and asking chatgpt), quickly leads to people recommending to just deploy a noop service worker, something like:

self.addEventListener('install', () => {
  self.skipWaiting();
});

self.addEventListener('activate', () => {
  self.clients.matchAll({
    type: 'window'
  }).then(windowClients => {
    windowClients.forEach((windowClient) => {
      windowClient.navigate(windowClient.url);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Another way of doing this, is just by calling the unregister method:

<script>
  navigator.serviceWorker.unregister();
</script>
Enter fullscreen mode Exit fullscreen mode

These options work well for the following scenario:

happy scenario

However, consider the following scenario:

unhappy scenario

So the question here is: when can you ever get rid of that code? When can you ever be sure that all your users have the noop service worker installed? And... then what happens to the noop service worker? Do they just have that installed forever? One way to achieve this is by basing it on analytics, but I wondered if there wasn't a different way of achieving this. For example, what if the service worker itself would have some kind of keepalive check built-in?

Here's the gist of it:

  • On every fetch request, the service worker sends a debounced message to the index.html; a keepalive check
    • If the index.html responds to that message, the service worker will stay alive
    • If the index.html does not respond to that message, the service worker will unregister itself

Here's an example:

<script>
  navigator.serviceWorker?.register("./sw.js").catch(console.error);
  navigator.serviceWorker?.addEventListener("message", (event) => {
    console.log("[Client]: Pong sent.");
    event.ports[0].postMessage('pong');
  });
</script>
Enter fullscreen mode Exit fullscreen mode
function checkPong(pong, interval = 500, maxInterval = 4000) {
  setTimeout(() => {
    if (!pong()) {
      if (interval < maxInterval) {
        console.log('[Deduping SW]: Pong not received. Checking again in', interval * 2, 'ms.');
        checkPong(pong, interval * 2);
      } else {
        console.log('[Deduping SW]: Unregistering.');
        self.registration.unregister();
      }
    } else {
      console.log('[Deduping SW]: Pong received.');
    }
  }, interval);
}

async function _keepaliveCheck(clientId) {
  const channel = new MessageChannel();
  let pong = false;

  channel.port1.onmessage = event => {
    if (event.data === 'pong') {
      pong = true;
    }
  };

  const client = await clients.get(clientId);
  if (client) {
    client.postMessage('ping', [channel.port2]);
    console.log('[Deduping SW]: Ping.');

    checkPong(() => pong);
  }
}

const keepaliveCheck = debounce(_keepaliveCheck, 2000);

self.addEventListener('fetch', event => {
  keepaliveCheck(event.clientId);
  // other SW-ey code
});
Enter fullscreen mode Exit fullscreen mode

This way, if we ever want to get rid of the service worker, we can just remove the following code from our index.html:

<script>
  navigator.serviceWorker?.register("./sw.js").catch(console.error);
  navigator.serviceWorker?.addEventListener("message", (event) => {
    console.log("[Client]: Pong sent.");
    event.ports[0].postMessage('pong');
  });
</script>
Enter fullscreen mode Exit fullscreen mode

Then, on the next keepalive check from the service worker, it will no longer get an answer from the index.html, and unregister itself; effectively ensuring that eventually every service worker will get unregistered, and we don't have any lingering code in our codebase because someone might still have a service worker installed.

Is this crazy? Maybe! Let me know on twitter or mastodon, because I'd love to hear some other thoughts about this!

💖 💪 🙅 🚩
thepassle
Pascal Schilp

Posted on March 25, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related