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 mode | HTML pages | Static assets | Images | Non-GET / API |
|---|---|---|---|---|
| Network first | Network first | Stale-while-revalidate | Stale-while-revalidate | Network only |
| Balanced (default) | Network first with offline fallback | Cache first | Stale-while-revalidate | Network only |
| Aggressive | Stale-while-revalidate | Cache first | Cache first | Network 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
- Only cache
GET. Checkrequest.methodfirst. Caching a POST response is nonsensical and some browsers throw. - Only cache successful responses.
response.okbeforeput. Otherwise a 500 during a deploy becomes your permanent homepage. - Never cache personalised HTML with cache-first. Cart pages, account pages, anything behind login: network only, or network first at most.
- Don't cache cross-origin opaque responses blindly. They count against quota at inflated sizes and you can't check their status.
- Serve
sw.jswith a short max-age. Browsers check it at most every 24 hours regardless, but a year-longCache-Controlon the file itself is a foot-gun on some hosts.
Debugging
Chrome DevTools → Application → Service 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.