A courier's orders app has to open in the basement of a warehouse where there is no network. That means its shell - the HTML, the CSS, the entry point, the offline screen - must already sit locally before the connection drops. Laying down that shell and later replacing it is the job of the two early service worker events: install and activate. A mistake in either leaves the user with a broken shell or a mix of old and new files - which is why both work atomically.
An ordinary handler finishes the moment its synchronous code returns. But installation is asynchronous: you cannot download files and arrange them in the cache instantly. The event.waitUntil() method solves this - it takes a Promise and extends the event's lifetime until that Promise settles. Until the Promise resolves, install counts as unfinished and the worker as not yet installed. Installation becomes atomic across the lifecycle: it either fully succeeded or fully failed.
Inside install it is convenient to open a named cache and drop the whole mandatory shell in with a single cache.addAll() call. addAll() has an important property: it is atomic. If even one URL fails to download, the whole call rejects, install fails, and the new worker does not activate - the previous, working one stays. For an app shell this is right: better an old but intact shell than a half-updated one. A rule: put only what you control into the mandatory precache. An unstable third-party script will break every install because of someone else's outage.
The activate event fires when the new worker is ready to take control, and it is the natural place for cleanup. Cache names change between versions - static-v41 gives way to static-v42 - and the old containers must be deleted, or storage will grow without bound. Deletion must be careful: only the caches this registration owns. You keep the list of allowed names explicit and erase everything else. A blind caches.delete() across all keys is dangerous if another app lives on the same origin.
With the shell in place, a third event comes into play - fetch. It turns the worker into a programmable proxy: the browser asks the worker about every network request the page makes, and the worker decides whether to answer from cache, go to the network, or build the response itself. The key constraint is that event.respondWith() must be called synchronously, right inside the handler. The response itself may arrive later: respondWith() accepts a Promise. But if you leave without calling respondWith(), the browser handles the request itself over the ordinary network.
Hence a style of routing: the worker should not intercept everything. It is sensible to filter out as early as possible what does not concern it - a foreign origin, non-GET methods - and simply return, letting the browser handle those requests. The rest are split across strategies based on request signals: the method, the origin, the path, request.mode (a navigation or a subresource) and request.destination (a document, an image, a script). Navigations are served one way, images another, the API a third.
A separate trap is responses from other origins in no-cors mode. Such a response is called opaque: its status is zero, its body and headers cannot be read. To the worker it is a black box - you cannot even check whether the request succeeded: response.ok is always false. Worse, opaque responses occupy in storage not their real size but an inflated one, padded up to a coarse boundary. An app that indiscriminately caches third-party icons can easily hit the quota and get write failures right where it stores its orders. So cross-origin responses are cached deliberately, with a limit on count and age.
const STATIC = 'static-v42';
const PRECACHE = ['/', '/offline.html', '/assets/app.83fd1.js'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC).then(cache => cache.addAll(PRECACHE))
);
});
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const allowed = new Set([STATIC, 'images-v3', 'api-v8']);
for (const name of await caches.keys()) {
if (!allowed.has(name)) await caches.delete(name);
}
})());
});self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET') return;
if (url.origin !== self.location.origin) return;
if (request.mode === 'navigate') {
event.respondWith(handleNavigation(event));
} else if (request.destination === 'image') {
event.respondWith(cacheFirst(request, 'images-v3'));
}
});