Service workers and offline

Write the three cache strategies that cover almost every case, and know how to get a bad one back out.

17of 18
≈ 13Min read
5Parts

A proxy you write, living beside the page

A service worker sits between the page and the network, on its own thread, and survives the page being closed. Every request the page makes passes through it, and whatever it returns is what the page gets — from the network, from a cache, or made up entirely.

It has no DOM, cannot touch the page directly, and only runs on https (or localhost). It is the machinery behind offline support, and it is also the easiest way to break a site for weeks.

registration, and the event that matters
// in the page
navigator.serviceWorker.register("/sw.js");

// in sw.js
self.addEventListener("fetch", (event) => {
  event.respondWith(handle(event.request));     // you decide what a request returns
});

The three strategies

Almost every real service worker is those three, chosen per URL pattern. Cache-first for /assets/, network-first for the HTML and the API, stale-while-revalidate for avatars and search suggestions.

StrategyServesFor
cache-firstthe cached copy if there is one, otherwise the networkhashed assets that never change
network-firstthe network, falling back to the cache when it failsanything that must be current
stale-while-revalidatethe cached copy immediately, and updates it in the backgroundcontent where slightly old is fine

Installing and taking over

A new worker installs in the background and then waits until every tab using the old one is gone — which for a pinned tab can be days. skipWaiting and clients.claim take over immediately, at the cost of a page that may now be running old code with a new worker. Version the cache name and delete the rest on activate, or the quota fills with every deploy you have ever shipped.

the lifecycle, and the two lines that skip the waiting
self.addEventListener("install", (e) => {
  e.waitUntil(caches.open("v3").then((c) => c.addAll(["/", "/app.css", "/app.js"])));
  self.skipWaiting();            // do not wait for every tab to close
});

self.addEventListener("activate", (e) => {
  e.waitUntil(
    caches.keys().then((keys) => Promise.all(
      keys.filter((k) => k !== "v3").map((k) => caches.delete(k))   // bin the old versions
    ))
  );
  self.clients.claim();          // control the pages that are already open
});

The ways it goes wrong

  • Caching the HTML cache-first. The page never updates, and the users who visited once are stuck on that version until they clear site data. This is the classic disaster.
  • Caching a response that failed. Check response.ok before putting anything in the cache, or you will serve a cached 500 offline forever.
  • Caching opaque cross-origin responses. They report status 0 and take up to 7 MB of quota each — several of them can fill the budget on their own.
  • Forgetting a way out. Ship an "unregister and clear" path from day one: registration.unregister() plus caches.keys().then(…delete…). Without it, a broken worker is unreachable from the server.
A service worker is code you cannot recall

Once installed it answers requests before the network is consulted, so a bug that stops it updating cannot be fixed by deploying. Keep the worker file itself uncached (Cache-Control: no-cache), keep it small, and test the update path — not just the first install.

Offline, honestly

An offline page is not "the site works offline". Decide what a visitor without a network should see: the last version of the page they read, a clearly-labelled cached copy, or a page that says the network is gone and the draft is saved. The worst outcome is a page that looks live and is three days old.

Practice