Build my web app

Service worker caching strategies, explained with code

What a service worker intercepts, the four strategies that cover nearly every case, how the builder's three modes map to them, and the rules for never serving the wrong thing from cache.

A service worker is a script the browser runs in its own thread, separate from any page, once you register it. It can intercept every network request your pages make and decide how to answer: from the network, from a cache it controls, or a combination. That decision is a caching strategy, and choosing the right one per request type is the whole craft.

The lifecycle in one paragraph

When the browser first sees sw.js it runs the install event — your chance to pre-fill a cache with essential files. When the worker takes control it runs activate — your chance to delete caches from older versions. From then on, every request from a page in scope fires a fetch event where you apply a strategy. If you later change a single byte of sw.js, the browser installs the new version in the background and activates it once all tabs of the old one are closed (or immediately, if you call skipWaiting()).

The four strategies

Cache first

Look in the cache; if the file is there, return it and never touch the network. If not, fetch it and cache it for next time.

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  const cache = await caches.open(CACHE);
  cache.put(request, response.clone());
  return response;
}

For: versioned assets that never change under the same URL — hashed CSS/JS bundles, fonts, icon files, game sprites. Never for: HTML that changes, or anything whose URL is reused for new content.

Network first

Try the network; if it responds, cache a copy and return it. If it fails (offline, timeout), return the cached copy.

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE);
    cache.put(request, response.clone());
    return response;
  } catch (e) {
    const cached = await caches.match(request);
    return cached || caches.match('/offline.html');
  }
}

For: HTML pages, API responses where freshness matters, anything a user would be upset to see stale. The cost is that it is only as fast as the network when online.

Stale-while-revalidate

Return the cached copy immediately, and in the background fetch a fresh one to replace it for next time.

async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE);
  const cached = await cache.match(request);
  const network = fetch(request).then(r => { cache.put(request, r.clone()); return r; });
  return cached || network;
}

For: things that change occasionally and where a one-visit lag is fine — avatars, product images, CSS that isn't hashed, a menu that updates weekly. Instant and mostly fresh.

Network only

Don't cache at all. For: anything non-GET (forms, checkout), authenticated API calls, analytics beacons, admin pages. The most important strategy is knowing what to leave alone.

How the builder's modes map

Builder modeHTML pagesStatic assetsImagesNon-GET / API
Network firstNetwork firstStale-while-revalidateStale-while-revalidateNetwork only
Balanced (default)Network first with offline fallbackCache firstStale-while-revalidateNetwork only
AggressiveStale-while-revalidateCache firstCache firstNetwork only

Pick by how bad stale content is for your site. A newspaper: Network first. A brochure or documentation site: Aggressive. Most things: Balanced.

Precaching on install

The install event is where you cache the shell: the start page, the offline page, core CSS/JS, the logo. Keep this list short — a large precache delays activation and wastes data for users who never come back.

const CACHE = 'app-v3';   // bump this on every deploy
const PRECACHE = ['/', '/offline.html', '/css/app.css', '/js/app.js', '/icons/icon-192.png'];

self.addEventListener('install', e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', e => {
  e.waitUntil(caches.keys().then(keys =>
    Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
  ).then(() => self.clients.claim()));
});

Versioning: the part people forget

Caches are keyed by name. If you deploy new CSS but keep app-v3, cache-first users see old CSS until the cache is evicted, which may be never. Change the version string on every deploy (the builder's sw.js has a CACHE_VERSION constant for exactly this), and the activate handler above clears the old one. If your build tool hashes filenames, cache-first is safe even without bumping — the URL itself changed.

Rules that prevent embarrassment

Debugging

Chrome DevTools → ApplicationService Workers shows the current worker, lets you force an update, simulate offline and unregister. Cache Storage beneath it shows exactly what is cached under each name. When a user reports "the site is stuck on an old version", this is where the answer is.