Self unregistering service workers
Pascal Schilp
Posted on March 25, 2024
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
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
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
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
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
It will get rewritten to:
https://cdn.ing.com/ing-web/2.44.0/es-modules/button.js
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());
});
And in the index.html
of our app, we register the service worker:
<script>
navigator.serviceWorker.register('./sw.js');
</script>
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);
});
});
});
Another way of doing this, is just by calling the unregister method:
<script>
navigator.serviceWorker.unregister();
</script>
These options work well for the following scenario:
However, consider the following 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 theindex.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
- If the
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>
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
});
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>
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!
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
November 13, 2024