HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 832 min read

Service Workers — Caching & the Update Lifecycle

Build One in 14 Lines, Then Meet the Trap

Web developmentweb development / offline & caching

The whole thing (sw.js)

const V = 'v3';
self.addEventListener('install', e => {
  e.waitUntil(caches.open(V).then(c => c.addAll(['/', '/app.css'])));
  self.skipWaiting();
});
self.addEventListener('activate', e => {
  e.waitUntil(caches.keys().then(ks =>
    Promise.all(ks.filter(k => k !== V).map(k => caches.delete(k)))
  ));
  self.clients.claim();
});
self.addEventListener('fetch', e => {
  if (e.request.method !== 'GET') return;
  e.respondWith(caches.match(e.request).then(hit => hit || fetch(e.request)));
});

What each line is actually teaching

  • const V = 'v3' — the Cache Storage API ignores Cache-Control entirely. Nothing expires. The cache NAME is your only versioning mechanism, and you own eviction by hand.
  • install + waitUntil — a new SW installs while the old one is still controlling every open tab. waitUntil keeps the worker alive until the promise settles; drop it and the browser may kill it mid-download.
  • skipWaiting() — without this, your new SW sits in "waiting" state until every tab of your origin is closed. A reload does NOT do it: the old page hands control straight to the next one. This is why "I deployed an hour ago and still see the old app."
  • the activate cleanup — old caches live forever otherwise. Storage quota eviction is per-origin and all-or-nothing: the browser nukes your whole Cache Storage, not just the stale entries.
  • clients.claim() — the page that registered the SW is uncontrolled on its first load. Without claim(), your fetch handler doesn't run until the next navigation.
  • if (method !== 'GET') return; — returning without calling respondWith() lets the request fall through to the network untouched. Only POSTs you never intended to cache. Forget this line and Cache.put() throws on every form submit.

The trap: that fetch handler is cache-first

Fast, offline-capable — and it will serve a broken '/' forever if you ship a bug, because a cached HTML hit never consults the network.

Saner defaults:

  • HTML / navigations → network-first, cache as fallback
  • hashed JS+CSS (app.a3f9c1.js) → cache-first is correct; the filename IS the version
  • API GETs → stale-while-revalidate: return the cached copy now, refetch in the background, update the cache
// stale-while-revalidate, roughly
const hit = await caches.match(req);
const net = fetch(req).then(r => { caches.open(V).then(c => c.put(req, r.clone())); return r; });
return hit || net;

One more deploy landmine

Browsers bypass the HTTP cache for sw.js itself (capped at 24h max-age), but a CDN in front of it doesn't know that. Put sw.js behind a 1-year edge TTL and your update mechanism is the thing that can't update. Serve it with Cache-Control: no-cache, from the origin, at the root scope.

Your exercise

Open a site you own in DevTools → Application → Service Workers. Register the 14-liner above, load the page twice, then change V to 'v4' and reload ONCE.

Question: does the new version take effect? Now comment out skipWaiting() and clients.claim() and repeat. How many reloads does it take — and what does the "waiting to activate" row tell you about your last production deploy?