Tags: web-dev concept

Cache API and Service Workers

Date: 2026-08-17


A script that sits between the page and the network, able to answer any request from a cache it controls. It’s the only way to make a site work offline — and its update lifecycle is genuinely counterintuitive, which is how sites end up serving a version from three deploys ago to returning visitors.


What it is

A service worker is a worker script registered against a scope, which the browser runs independently of any page and consults for every network request in that scope.

            ┌──────────────┐
   page ───▶│service worker│───▶ Cache API   (programmable, yours)
            └──────┬───────┘
                   └────────▶ network        (only if you ask)

it survives page navigations and tab closes.
it can answer a request without touching the network at all.

Distinct from a web worker, which is a computation thread. Same “off the main thread” property, entirely different purpose.

Requires HTTPS, except on localhost — because a script that can rewrite every response is a serious attack surface if injectable over plain HTTP.

The smallest working form

// sw.js
const VERSION = 'v4';                     // ← bump to invalidate everything
 
self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open(VERSION).then(c => c.addAll(['/', '/offline.html', '/app.css']))
  );
});
 
self.addEventListener('activate', (e) => {
  e.waitUntil(                            // delete every old version's cache
    caches.keys().then(keys => Promise.all(
      keys.filter(k => k !== VERSION).map(k => caches.delete(k))
    ))
  );
});
 
self.addEventListener('fetch', (e) => {
  e.respondWith(
    caches.match(e.request).then(hit => hit || fetch(e.request))
  );
});

That last handler is cache-first, and it’s the dangerous default — see the strategies below.

The update lifecycle, which is the trap

1  new sw.js downloaded, byte-different from the current one
2  INSTALL fires → precaching happens
3  the new worker WAITS
     ← it does NOT take over. the old one is still serving every page
4  it activates only when EVERY tab controlled by the old worker closes
     ← reloading is not enough. a reload keeps the old worker alive
5  ACTIVATE fires → clean up old caches

Step 4 is the part that catches everyone. A user with your site open in a pinned tab may not close it for weeks, and until they do they are served the old worker and whatever it decides to cache. “I’ve deployed three times and they’re still seeing the old version” is this.

Two escapes, and both need care:

self.skipWaiting();          // in install: take over immediately
self.clients.claim();        // in activate: control existing pages

skipWaiting swaps the worker under a running page, so a page that has already loaded app.a1b2.js may then request a lazy chunk that no longer exists in the new deployment. The safe pattern is to detect the waiting worker, tell the user, and activate on their action:

// in the page
navigator.serviceWorker.addEventListener('controllerchange',
  () => location.reload());              // reload once the new one takes over
// then post skipWaiting to the waiting worker when the user clicks "Update"

The strategies

Choose per request type. Getting this wrong is where the real damage is.

StrategyBehaviourRight forWrong for
Cache firstCache, else networkHashed static assets — app.a1b2c3.jsAnything that changes at a stable URL
Network firstNetwork, fall back to cacheHTML, API responsesSlow networks — you wait for the timeout
Stale-while-revalidateServe cache now, update behindAvatars, non-critical contentPrices, stock
Network onlyNever cacheCheckout, payment, auth—
Cache onlyNever networkPrecached offline pageEverything else

Cache-first on HTML is the catastrophic one. The URL /product/1234 doesn’t change when the page does, so a cache-first worker serves last week’s price indefinitely. Content-hashed asset filenames are what make cache-first safe, because a new build produces a new URL — HTTP Caching, Module Bundling.

Commerce-specific rules:

  • Never cache checkout, basket, payment or authenticated responses. Serving one customer’s cached basket to another on a shared device is the worst available outcome — Multi-Tenancy, Authorisation Models
  • Never cache prices or stock from a cache-first strategy. Showing a stale price is a consumer-law problem, not just a bug — Testing and Compliance
  • An offline page is the realistic goal for most retail sites, not full offline commerce

What it costs

  • You have added a proxy you must maintain. A bug in the fetch handler breaks the site for returning visitors, and it persists after you deploy a fix — because the buggy worker is still in control
  • Debugging is genuinely harder. DevTools’ Application panel has “Update on reload” and “Bypass for network” — turn both on while developing, or you’ll be testing an old worker
  • A kill switch is mandatory. Ship a way to unregister everything and clear caches, before you ship the worker:
self.registration.unregister()
  .then(() => caches.keys())
  .then(keys => Promise.all(keys.map(k => caches.delete(k))));

When it’s worth it

Yes: repeat-visit-heavy sites where the asset cache genuinely helps; an offline fallback page; push notifications, which require a service worker; background sync for actions taken on a poor connection.

No, usually: a first-visit-dominated retail site, where a service worker does nothing for the visit that matters and adds a maintenance liability. Measure your returning-visitor share before building one — the benefit is entirely on repeat visits, and on many retail sites that’s a minority of traffic.

Where it interacts

  • HTTP Caching — the layer beneath; a service worker overrides it entirely for requests it answers
  • Client Storage — the Cache API is one of several storage mechanisms, with its own quota
  • Web Workers — the other off-main-thread script, routinely confused with this
  • Rollback and Forward Fix — a bad service worker is the hardest deployment to roll back, which is why the kill switch ships first