cf.completefrontend
My progress
FUNDAMENTALS · MODULE 9

Service worker caching

Using a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.

The problem the HTTP cache can't solve

Picture a news reader app that a commuter opens every morning on a train. For the first ten minutes the phone has a good 4G signal. Then the train enters a tunnel, and for two minutes the phone shows full bars but nothing comes back. People call this lie-fi: the device thinks it is online, but requests hang.

The app's JavaScript bundle, CSS, and fonts are already in the browser's HTTP cache, with long max-age values and hashed file names. That part works well. The problem is the HTML page itself and the article list.

The server marks them no-cache, because they change every few minutes, so every visit asks the server first. In the tunnel, that request hangs for 30 seconds or more, and the commuter stares at a white screen.

What the team wants is simple to say: "Try the network for the article list. If nothing comes back within 3 seconds, show yesterday's list with a banner that says it might be out of date." The HTTP cache can't do that. It follows rules written in response headers, and those rules have no idea about timeouts, banners, or a phone stuck in a tunnel.

What the HTTP cache can't do

The lessons on HTTP caching and CDN caching show how far headers alone go. They go a long way. But they have hard limits, and every limit below is something real apps need.

  • No fallback when the network fails. If a response must be checked with the server and the server is unreachable, the browser shows its offline error page. The stale-if-error directive says "if the check fails, use the old copy anyway", but browsers largely ignore it; it works mainly at CDNs.
  • No timeouts. There is no header for "wait at most 3 seconds". A slow network is often worse than no network, because the browser keeps waiting.
  • No control over what stays. The browser evicts HTTP cache entries whenever it likes, to make room. You can't say "always keep the app shell".
  • No logic per request. You can't say "for avatar images, show the old one now and refresh it quietly" or "for this API, answer from cache only when the user is offline".
  • No offline first load of a route. If the user has never visited /settings, the HTTP cache has nothing for it, even though the app could render it from files it already has.

The answer: a proxy you write yourself

A service worker is a JavaScript file that the browser runs in the background, separate from your page. Once it is installed, the browser sends it a fetch event for every request the page makes, including the request for the page itself. The service worker decides how to answer: from a stored copy, from the network, from both, or with a response it builds on the spot.

To store copies, it uses the Cache API: named caches that hold pairs of request and response. Unlike the HTTP cache, nothing in the Cache API expires on its own. Your code puts things in and your code takes them out.

With those two pieces, the news app's wish becomes about 15 lines of code. The app shell (the HTML, JS, and CSS needed to draw the empty frame of the app) loads from the Cache API in a few milliseconds. The article list tries the network, and after 3 seconds falls back to the last list it saved. The commuter sees the app at once, every time, tunnel or not.

This lesson covers the service worker lifecycle, the Cache API, the five caching strategies with code, precaching the app shell, versioning and cleanup, navigation preload, opaque responses, update UX, and Workbox. Storage limits are covered in depth in the client storage and IndexedDB lessons, and keeping caches correct after deploys in cache invalidation. Full offline apps, background sync, and push are in the offline module, starting with service workers and offline-first.

The mental model: a proxy that lives in the browser

Think of the service worker as a small proxy server that you deploy into each user's browser. It sits between your pages and everything else. Every request from a page it controls goes to it first, and it answers with a Response. Where that response comes from is up to your code.

An everyday comparison: a personal assistant at your office door. Visitors (requests) don't walk straight to you (the network). The assistant checks a filing cabinet (the Cache API) and decides: hand over the copy on file, phone you for a fresh one, or do both and swap the paper later. You write the assistant's rules in advance.

A layered stack. At the top, your page calls fetch for /api/feed. Inside the browser, the request reaches the service worker first. The worker can call match on the Cache API beside it, which your code fills and empties, or call fetch, which goes to the HTTP cache run by Cache-Control headers. The Cache API and the HTTP cache don't know about each other. Below the browser, on the network, a CDN edge and then the origin server.Your pagefetch('/api/feed')BrowserService workeryour fetch handlerCache APIyour code fills itmatch()fetch()HTTP cacheCache-Control rulestwo caches, no linkNetworkCDN edgeOrigin
The worker answers before the HTTP cache is even asked. When it calls fetch(), the request takes the normal path: HTTP cache, CDN, origin.

Where it sits among the other caches

The order matters. A request from the page reaches the service worker before it reaches the HTTP cache. If the service worker answers from the Cache API, the HTTP cache and the network are never touched. If the service worker calls fetch(), that fetch goes through the normal path: the HTTP cache first, then the CDN, then your origin server.

  • Memory cache: a short-lived cache inside the page for things like images already decoded in this document. It can answer before the service worker sees the request.
  • Service worker: your code. Sees the request, may answer from the Cache API, may call fetch().
  • HTTP cache: the browser's disk cache, run by Cache-Control and friends. Used by the service worker's own fetch() calls too.
  • CDN and origin: the network side, covered in the CDN caching lesson.

This means you now have two browser caches for the same file: the HTTP cache and the Cache API. They don't know about each other. A common surprise is a service worker that fetches /app.js to refresh its cache and gets a stale copy back from the HTTP cache. When a strategy must hit the server, pass { cache: "no-cache" } to fetch() or make sure the headers are right.

Registration and scope

A page installs a service worker by registering a script URL. The browser downloads the script, runs it in a separate thread, and from then on it controls pages inside its scope: the URL path prefix it is allowed to handle.

js
// main.js, in the page
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker
      .register("/sw.js", { scope: "/" })
      .then((reg) => console.log("scope:", reg.scope))
      .catch((err) => console.error("SW failed", err));
  });
}
Register after the load event so the worker's install doesn't compete with the first page load for bandwidth.
  • HTTPS only. Service workers need a secure context, because a worker that can rewrite every response is too dangerous over plain HTTP. localhost is allowed for development.
  • Same origin. The script must come from the same origin as the page. You can't register a worker hosted on your CDN domain.
  • Scope defaults to the script's folder. /sw.js can control the whole site. /static/js/sw.js can only control URLs under /static/js/, which is almost never what you want. Serve the worker from the root, or send the Service-Worker-Allowed: / header with the script to widen it.
  • One active worker per scope. Two registrations with different scopes can exist; the most specific scope wins for a given page.

Check your understanding · Exercise 1

A team serves its worker from /assets/sw.js with no extra headers and registers it from the home page. Users report the app never works offline. What is the most likely cause? Pick one.

The lifecycle: install, wait, activate

Most service worker bugs are lifecycle bugs. The lifecycle exists for one reason: a page should run against one version of the worker from start to finish. If version 2 of the worker took over a tab that was loaded by version 1, the old page's JavaScript could ask for files that version 2 has already deleted. So the browser keeps versions apart, and that has costs you must design around.

A state machine for one worker version. Installing moves to waiting when install succeeds, or to redundant when install fails. Waiting moves to activating when every tab using the old version is closed, or when skipWaiting is called. Activating moves to active when the activate handler finishes; the active worker controls pages. Active moves to redundant when a newer version replaces it. A note says waitUntil with a promise keeps a phase open until the promise settles.installinginstall eventwaitingold tabs openactivatingactivate eventredundantthrown awayactivecontrols pagesinstall oktabs closedor skipWaiting()activate doneinstall failedreplaced by a newer onewaitUntil(promise) keeps a phase openuntil the promise settles
The waiting state is the one people forget. It protects open tabs from a worker they didn't load with, and skipWaiting() is the only way to jump it.

The phases one by one

  1. Download and parse. The browser fetches the script and runs its top level. Syntax errors stop everything here.
  2. Install. The browser fires an install event once per version. This is where you precache the app shell. If the promise you pass to event.waitUntil() rejects, install fails and this version is thrown away. The old worker, if any, keeps running.
  3. Waiting. If an older version still controls any open tab, the new one waits. It stays in this state until every tab controlled by the old version is closed. A reload is not enough for a single tab, because the new page starts loading before the old one goes away, so the old worker always has a client.
  4. Activate. Once nothing uses the old version, the new one gets an activate event. This is the safe moment to delete old caches, because no page needs them anymore.
  5. Active and controlling. The worker now receives fetch events for pages loaded inside its scope. The browser stops its thread when it is idle (in Chrome after about 30 seconds; other browsers set their own limits) and starts it again on the next event, so never keep state in global variables.
  6. Redundant. A worker that failed to install, or was replaced by a newer one, is discarded.
js
// sw.js
const VERSION = "v42";
const SHELL = `shell-${VERSION}`;
const SHELL_FILES = ["/", "/app.3f9a1c.js", "/app.77be02.css", "/offline.html"];

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(SHELL).then((cache) =>
      // cache: "reload" skips the HTTP cache, so "/" is never an old copy
      cache.addAll(SHELL_FILES.map((url) => new Request(url, { cache: "reload" })))
    )
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names.filter((n) => n.startsWith("shell-") && n !== SHELL).map((n) => caches.delete(n))
      )
    )
  );
});
Install fills a new cache named after the version. Activate deletes the caches of older versions, once no page uses them.

Shipping worker v2 to a tab that runs v1Step 1 of 6: v1 in charge

One open tab, worker v1 and its cache shell-v1, and a new worker v2 with its cache shell-v2. Step by step: v1 controls the tab; the deploy makes the browser install v2, which fills shell-v2; v2 waits because the tab still uses v1; the user clicks Reload, the page posts SKIP_WAITING; v2 activates, deletes shell-v1, and v1 becomes redundant; the page reloads and now runs page v2 under worker v2.app tabpage v1worker v1activeshell-v14 filescontrolled by v1
The tab loaded with worker v1 in control. Every request goes through v1, which serves the shell from the shell-v1 cache.

v1 in charge: The tab loaded with worker v1 in control. Every request goes through v1, which serves the shell from the shell-v1 cache.

skipWaiting and clients.claim, and their risks

Two calls let you shortcut the lifecycle. They are popular in tutorials and they are where most production incidents start.

  • self.skipWaiting() (called in the worker, usually during install) tells the browser not to wait. The new version activates right away, even while old tabs are open. Those old tabs are now served by the new worker.
  • self.clients.claim() (called during activate) makes the newly active worker take control of open pages that it does not control yet, including the very first page that registered it. Without it, that first page runs without a worker until the next navigation.

The risk of skipWaiting() is version mixing. Say version 1 of your app is open in a tab. It loads route chunks lazily: when the user clicks Settings, it requests /settings.a1b2.js.

Meanwhile, version 2 of the worker activated in the background, and its activate handler deleted the version 1 cache. The deploy also removed settings.a1b2.js from the server, because version 2 ships settings.c3d4.js. The click fails with a chunk load error, and the user sees a broken screen.

The risk of clients.claim() is smaller but real: a page that loaded without a worker suddenly has one, halfway through its life. Requests it makes after that point go through the worker. That's usually fine for the first visit, but be aware of it when you debug "why did this one request come from the cache?".

Decision How should a new version of the worker take over?

Default: wait for all tabs to close

Don't call skipWaiting(). The new version activates when the user closes every tab of the app.

Pros

  • Safest: a page never runs against a different worker than the one it loaded with.
  • No code needed.

Cons

  • Users who keep a tab open for days (email, chat, dashboards) stay on the old version for days.
  • Hard to explain to people who expect a reload to update the app.

Always skipWaiting

Call self.skipWaiting() in install and clients.claim() in activate.

Pros

  • Every user gets the new worker within seconds of the next visit.
  • Fine for workers that only do runtime caching with no version-specific caches.

Cons

  • Old tabs run old JavaScript against a new worker and new caches: missing chunks, mismatched API shapes.
  • Bugs appear only for people with long-lived tabs, so they are hard to reproduce.

Ask the user, then skipWaiting

The page detects the waiting worker, shows "A new version is available" with a Reload button.

On click, the page posts a message, the worker calls skipWaiting(), and the page reloads on controllerchange.

Pros

  • The switch happens together with a reload, so no page ever runs mixed versions.
  • The user chooses the moment, so they don't lose a half-written form.

Cons

  • More code in the page and the worker.
  • Some users never click, so you may also need a rule like "reload on the next navigation".

What we pick: For an app with a precached shell and lazy chunks, ask the user and reload together with the switch (the updates chapter shows the code). Use plain skipWaiting() only when the worker holds no version-specific files, or when your server keeps old hashed files for a few weeks so old tabs can still load them.

Check your understanding · Exercise 2

You deploy a new worker. You have one tab of the app open and press reload. DevTools shows the new worker as "waiting to activate". Why? Pick one.

The Cache API in detail

The Cache API is the filing cabinet. It is available as caches both in the worker and in normal pages. It holds named caches, and each cache is a list of pairs: a Request as the key and a Response as the value. That's it: no expiry times, no size limits per cache, no automatic cleanup.

The Cache API for one origin, drawn as three named caches of request and response pairs. shell-v42 holds GET / with a 200 HTML response of 14 KB that never expires on its own, GET /app.3f9a1c.js at 180 KB, and GET /offline.html at 2 KB. images holds GET /u/81.webp at 9 KB and a cross-origin GET for cdn.other/i.png stored as an opaque response with status 0, which Chrome counts as about 7 MB of quota. shell-v41, from the old version, holds GET / at 13 KB and should be deleted.key: Requestvalue: Responseshell-v42GET /200 html 14 KBno expiryGET /app.3f9a1c.js200 js 180 KBGET /offline.html200 html 2 KBimagesGET /u/81.webp200 webp 9 KBGET cdn.other/i.pngopaque, status 0~7 MB quotashell-v41 (old version)GET /200 html 13 KBdelete it
Keys are requests, values are whole responses. Nothing here expires; old versions stay until your activate handler deletes them.

The methods that matter

MethodWhat it doesCommon mistake
caches.open(name)Opens a cache by name, creating it if needed.Using one name forever, so old versions pile up in it.
cache.addAll(urls)Fetches every URL and stores all the responses, or none of them.Listing a URL that returns 404: the whole call rejects, so install fails.
cache.put(req, res)Stores a response you already have, with no checks.Storing 404 and 500 responses, then serving them for days.
cache.match(req, opts)Finds a stored response for a request, or undefined.Forgetting query strings: ?v=2 is a different key unless you pass ignoreSearch: true.
caches.match(req)Searches every cache, oldest first, and returns the first hit.Getting an old version's copy because two caches hold the same URL.
cache.delete(req) and caches.delete(name)Removes one entry, or a whole cache.Never calling them, so storage grows until the browser evicts everything.
cache.keys()Lists the stored requests.Calling it on a big cache in every fetch: it's slow.

Rules you only learn by breaking them

  • Only GET. cache.put() throws for a POST request. To keep form posts or mutations offline, store them in IndexedDB and replay them; the offline module's background sync lesson covers that.
  • A body can be read once. A Response is a stream. If you return it to the page and also put it in the cache, clone it first: cache.put(req, res.clone()) and then return res.
  • Matching uses the URL and, by default, Vary. If the stored response has Vary: Accept-Language and the new request has a different language, it won't match. Options ignoreSearch, ignoreMethod, and ignoreVary relax the rules.
  • Headers don't apply. A stored response with Cache-Control: max-age=60 is served for a year if your code serves it. The Cache API never reads freshness headers. If you want expiry, you write it (or use Workbox's expiration plugin).
  • It's shared with the page. Pages can read and write the same caches. Handy for "save this article for offline" buttons, risky if two codebases fight over names. Prefix names: news-shell-v42, news-images.

Opaque responses and their quota padding

When a page loads a cross-origin file without CORS (a third-party image tag, or fetch(url, { mode: "no-cors" })), the browser gets an opaque response. Your code can't see its status, headers, or body. response.status is 0 and response.ok is false, even if the server sent a 200 or a 500.

You may store opaque responses, but there are two traps. First, you can't tell success from failure, so you may cache an error page and serve it as an image for months.

Second, to stop sites from learning the size of cross-origin files through storage quota, browsers count each opaque response as much larger than it is. In Chrome each one costs about 7 MB of quota. Cache 100 tiny third-party icons and you have used about 700 MB of the origin's budget on paper.

All of this storage counts against the origin's quota, shared with IndexedDB and other site storage. navigator.storage.estimate() tells you usage and quota, and navigator.storage.persist() asks the browser not to evict your data under pressure. The client storage lesson covers the numbers per browser. One thing to know here: when the browser does evict, it removes all storage for the origin at once, not one cache at a time, so your worker must always cope with an empty cache.

Check your understanding · Exercise 3

A worker does const res = await fetch(req); await cache.put(req, res); return res;. Pages get an error about a body that was already used. What is the fix? Pick one.

The five strategies, with code

Every fetch handler is a choice about two sources: the cache and the network. There are five common ways to combine them. They have names you should use in interviews, because everyone knows them. Each one below comes with the code, what it is good for, and how it fails.

js
self.addEventListener("fetch", (event) => {
  const req = event.request;
  if (req.method !== "GET") return; // let the browser handle it
  const url = new URL(req.url);

  if (req.mode === "navigate") return event.respondWith(networkFirst(req, 3000));
  if (url.pathname.startsWith("/assets/")) return event.respondWith(cacheFirst(req));
  if (url.pathname.startsWith("/avatars/")) return event.respondWith(staleWhileRevalidate(event));
  // anything else: no respondWith, so the request goes to the network as usual
});
A router: pick a strategy by request type. Not calling respondWith() means the browser handles the request normally.

Cache first

Look in the cache. If there's a copy, return it without touching the network. If not, fetch, store a copy, and return it. After the first time, it costs zero network and a few milliseconds.

js
async function cacheFirst(req) {
  const cached = await caches.match(req);
  if (cached) return cached;
  const res = await fetch(req);
  if (res.ok) {
    const cache = await caches.open("assets");
    cache.put(req, res.clone());
  }
  return res;
}

Use it for files whose URL changes when their content changes: hashed bundles like app.3f9a1c.js, fonts, versioned images. It fails when the URL stays the same and the content changes. Use cache first on /logo.png and the old logo stays forever, because nothing ever asks the network again.

Network first, with a timeout

Ask the network. If it answers, store a copy and return it. If it fails, or takes longer than a limit, return the cached copy. Users get fresh data when the network is good and something useful when it isn't.

js
async function networkFirst(req, timeoutMs) {
  const cache = await caches.open("pages");
  const network = fetch(req).then((res) => {
    if (res.ok) cache.put(req, res.clone());
    return res;
  });
  const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs));
  const winner = await Promise.race([network.catch(() => undefined), timeout]);
  if (winner) return winner;
  const cached = await cache.match(req);
  return cached || network.catch(() => caches.match("/offline.html"));
}
Without the timeout, a hanging connection makes the user wait for the browser's own network timeout, which can be tens of seconds.

Use it for HTML pages and API data where fresh matters but stale beats nothing: an inbox, a news list. It fails on lie-fi if you skip the timeout, and the timeout is a guess: 3 seconds is common, but too short a value serves stale data on slow but working connections.

Stale while revalidate

Return the cached copy at once, and at the same time fetch a fresh one to update the cache for next time. The user always gets an instant answer, at most one version behind. It's the same idea as the stale-while-revalidate directive from the HTTP caching lesson, but you control it in code and it works offline.

js
async function staleWhileRevalidate(event) {
  const cache = await caches.open("runtime");
  const cached = await cache.match(event.request);
  const refresh = fetch(event.request).then((res) => {
    if (res.ok) cache.put(event.request, res.clone());
    return res;
  });
  event.waitUntil(refresh.catch(() => {})); // keep the worker alive for the update
  return cached || refresh;
}

Use it for things where being one visit behind is fine: avatars, a product catalogue that changes daily, a user's settings. It fails for data where stale is wrong (a bank balance, a price at checkout), and it costs a network request on every use, so it doesn't save bandwidth. The user sees new data only on the next load, unless you tell the page (Workbox has a broadcast plugin for that).

Network only and cache only

  • Network only: always the network, never stored. For POST requests, payments, analytics beacons, and anything personal that must not sit on disk. In plain code, simply don't call respondWith().
  • Cache only: always the cache, never the network. For files you precached at install and that can't change until the next version, like the app shell. If the entry is missing, the request fails, so use it only when install guarantees the entry.
A sequence diagram of stale while revalidate between the page, the worker, the Cache API, and the network. The page asks for /avatars/7. The worker calls match on the cache, which returns the old copy in 4 milliseconds. The worker answers the page with the old copy at once. Then, in the background, the worker fetches from the network, which answers 200 after 320 milliseconds, and the worker puts a clone in the cache for next time.PageWorkerCache APINetworkGET /avatars/7match()old copy, 4 msold copy, at oncefetch() later200, 320 msput(clone)
The user waits 4 ms, not 320 ms. The fresh copy only shows on the next load. event.waitUntil() keeps the worker alive until the background put() finishes.

Check your understanding · Exercise 4

An e-commerce site shows the cart total in the header, fetched from /api/cart. Which strategy fits it best? Pick one.

Choosing a strategy per request

No single strategy is right for a whole app. The skill is matching each kind of request to a strategy based on two questions: how bad is stale data here? and how bad is waiting? Try the lab below. Pick a strategy and a network condition, and see what the user gets and how long they wait.

Strategy:
Network:
Cache:
Network first (3 s timeout) on a Lie-fi (hangs) network with yesterday's copy cached. The user sees yesterday's copy after 3 s. The user waits on the network. The cache is unchanged.01 s2 s3 s4 s5 scachenetworkhangs, 30 s or morescreenyesterday's copyblank or spinner for 3 s
The screen row is what the user experiences. The axis stops at 5 seconds; a hanging request runs off the end.

The user sees yesterday's copy after 3 s. After 3 seconds the timeout wins and yesterday's copy appears. Without the timeout the user would wait for the browser to give up.

A few things the lab shows are easy to miss. With a copy stored, cache first and stale while revalidate feel identical to the user on every network: instant. The difference is only what happens next time.

Network first and network only are the strategies where the network condition changes the wait, and on a hanging network, network first is only as good as its timeout. Every strategy that reads the cache is also only as good as what was stored. With an empty cache, cache first, network first, and stale while revalidate all behave like network only, and cache only simply fails.

A map for a typical app

RequestStrategyWhy
App shell HTML (SPA)Precached, cache only, updated by new versionsInstant start; the worker version decides which shell you get.
Server-rendered pagesNetwork first, 2 to 4 s timeout, offline page fallbackContent changes often; stale beats a white screen.
Hashed JS, CSS, fontsCache first (or precache)The URL changes when the content does, so a cached copy is never wrong.
Unversioned images, avatarsStale while revalidate, capped at about 100 entriesInstant, and refreshed in the background.
Read APIs (feed, catalogue)Network first, or stale while revalidate if one load behind is fineDepends on how bad stale is.
Writes (POST, PUT, DELETE)Network only, plus a queue in IndexedDB if offline writes matterThe Cache API can't store them.
Auth, payments, analyticsNetwork onlyNever on disk; never served stale.
A comparison matrix of the five strategies, once a copy is cached, against four properties: instant, fresh, works offline, and saves data. Cache first: instant yes, fresh no, offline yes, saves data yes. Network first: instant no, fresh yes, offline yes, saves data no. Stale while revalidate: instant yes, fresh later, meaning on the next load, offline yes, saves data no. Network only: instant no, fresh yes, offline no, saves data no. Cache only: instant yes, fresh no, offline yes, saves data yes.instantfreshofflinesaves dataCache firstyesnoyesyesNetwork firstnoyesyesnoStale while revalidateyeslateryesnoNetwork onlynoyesnonoCache onlyyesnoyesyes
No row is yes everywhere. Instant and fresh pull against each other; stale while revalidate splits the difference by being fresh one load later.

Decision How should the worker answer navigation requests (the HTML page)?

Precached app shell, cache only

Precache /index.html at install.

Answer every navigation with it; the client-side router renders the route and fetches data.

Pros

  • Instant start on every visit, online or offline.
  • Works for routes the user never visited.

Cons

  • Only fits client-rendered apps.
  • Content updates need a new worker version; the shell can be one version behind until the update flow runs.

Network first with timeout

Fetch the page; after 3 seconds or on failure, serve the cached copy of that URL or an offline page.

Pros

  • Fresh server-rendered HTML when the network works.
  • Works with SSR and with pages that change per request.

Cons

  • Online users still wait for the server on every visit.
  • Routes never visited have no copy, only the offline page.

Stale while revalidate

Serve the cached page at once and refresh it in the background.

Pros

  • Instant for any page visited before.

Cons

  • Users routinely see the previous version of the page, which looks like a bug on content sites.
  • HTML that references hashed assets can point to files that no longer exist.

What we pick: For a client-rendered app, precache the shell and serve it for every navigation. For server-rendered or content sites, use network first with a timeout and an offline page, and turn on navigation preload (next chapter) so the worker doesn't slow the network path down.

Think first

Your worker uses cache first for /assets/. A user visits for the first time while offline. What happens?

Precaching the app shell, versioning, and navigation preload

Precaching means downloading a known list of files during install, before any page asks for them. Runtime caching (the strategies above) stores things as the user stumbles on them. Precaching stores the files the app can't start without, so the second visit starts from disk even offline.

The revision manifest

Writing the file list by hand breaks on the first deploy. Instead, the build tool writes a precache manifest: every file in the shell with a revision. For files with a content hash in the name, the URL is the revision. For files without one, like /index.html, the tool adds a hash of the content.

The precache manifest for version 42, one entry per line, with notes for an update from version 41. /index.html with revision a91f3c: changed since version 41, so it is downloaded. /app.3f9a1c.js with revision null: the hash in the name is the revision, and it's a new file, so it is downloaded. /app.77be02.css with revision null: same URL as before, so it is reused. /offline.html with revision 0d42e7: same revision, reused. The list is pasted into sw.js, so any change makes new worker bytes.sw.js, version 42: the precache list/index.html rev a91f3cchanged: download/app.3f9a1c.js rev nullnew hash: download/app.77be02.css rev nullsame URL: reuse/offline.html rev 0d42e7same rev: reuseany line changes, so sw.js changes, so an update starts
Files with a hash in the name need no revision. Files without one get a content hash from the build. Because the list lives inside sw.js, changing any shell file changes the worker, which starts an update.

The build pastes this manifest into the worker script. That has a useful effect: when any shell file changes, the manifest changes, so the worker script's bytes change, so the browser sees a new worker version. Unchanged files keep their revision and don't need to be downloaded again; a good precacher reuses the copy it already stored instead of fetching it.

  • Keep it small. The shell is what you need to draw the first screen: HTML, the main JS and CSS bundle, the main font, the logo, an offline page. Often 200 to 500 KB compressed. Precaching every route chunk and image of a large app can mean 10 MB on first visit, paid by users who may never come back.
  • Install is all or nothing. If one URL in the list returns 404, addAll() rejects and the whole version is thrown away. That's what you want (no half-shell), but a single typo in the manifest means no user ever gets the new version. Monitor install failures.
  • Bypass the HTTP cache when precaching. Pass requests built with new Request(url, { cache: "reload" }) to addAll(), as the install code in the lifecycle chapter does (or add the revision as a query string), so you don't precache an old copy from the HTTP cache under a new revision.

Versioning and cleanup of old caches

The Cache API never deletes anything for you. Every version that creates a new cache and never deletes the old one adds a full copy of the shell to the user's disk. After 50 deploys, that's 50 copies. Eventually the browser runs short of space and evicts the whole origin, including data you wanted to keep.

  • Name caches by purpose and version: shell-v42 for the precache, images and pages for runtime caches that survive versions.
  • Delete in activate, not install. In install, the old version still serves open tabs and needs its cache. In activate, no page uses it anymore.
  • Only delete what you own. Filter by your prefix. Deleting every cache that isn't the current one also wipes caches that pages created for "save for offline".
  • Cap runtime caches. Give image and API caches a maximum entry count and age, and trim them after each put. Workbox's expiration plugin does this for you.

A worker that isn't running has to start before it can handle a request. Chrome's engineers measured about 50 ms as typical, around 250 ms on mobile, and over 500 ms on slow devices. For network-first navigations, that start-up time is pure delay: the network request can't begin until the worker's fetch handler runs.

Two timelines for one network-first navigation, with an example worker start-up of 250 milliseconds and a 400 millisecond network request. Without navigation preload, the request can only start after the worker has booted, so the HTML arrives at 650 milliseconds. With navigation preload, the browser starts the request while the worker boots, so the HTML arrives at 400 milliseconds.without navigation preloadworker bootpage requestHTML arrives at 650 mswith navigation preloadworker bootpage requestHTML arrives at 400 ms
Worker start-up time varies by device: about 50 ms is typical, around 250 ms on mobile, more on slow phones. Preload overlaps it with the network instead of adding it on top. The handler reads event.preloadResponse.

Navigation preload fixes this. You enable it once, in activate. From then on, for every navigation, the browser starts the network request at the same time as it boots the worker, and the handler reads the result from event.preloadResponse instead of calling fetch() again.

The server sees the header Service-Worker-Navigation-Preload: true, so it can send a smaller response if it wants. Chromium browsers, Firefox (since version 99), and Safari (since 15.4) support it; the ?. in the code below skips it where it's missing.

js
self.addEventListener("activate", (event) => {
  event.waitUntil(self.registration.navigationPreload?.enable());
});

self.addEventListener("fetch", (event) => {
  if (event.request.mode !== "navigate") return;
  event.respondWith((async () => {
    try {
      const preloaded = await event.preloadResponse;
      return preloaded || (await fetch(event.request));
    } catch {
      return (await caches.match(event.request)) || caches.match("/offline.html");
    }
  })());
});

Check your understanding · Exercise 5

A developer moves the "delete old caches" code from activate to install, to free space sooner. What goes wrong? Pick one.

Shipping updates: detection and the reload prompt

With an app shell served from the Cache API, your deploy doesn't reach users when they load the page. It reaches them when the browser notices a new worker script, installs it, and activates it. Understanding that path is the difference between "users get fixes in minutes" and "half our users run last month's build".

How the browser finds a new version

  • When it checks. On every navigation to a page in scope, and on other worker events (push, sync) if it hasn't checked in the last 24 hours. Your page can also call registration.update(), for example every hour in a tab that stays open all day.
  • What it compares. It downloads the worker script and compares it byte for byte with the installed one, including scripts it loads with importScripts(). Any difference means a new version. No difference means nothing happens, even if your precached files changed on the server. That's why the manifest with revisions lives inside the script.
  • The HTTP cache is skipped for the script. By default (updateViaCache: "imports") the browser fetches the main worker script past the HTTP cache. Still, serve sw.js with Cache-Control: no-cache so CDNs and proxies don't hold an old copy.
  • Never rename the worker file per deploy. If page version 1 registers /sw-v1.js and that page is itself served from the cache by sw-v1.js, the page never learns about sw-v2.js. Keep one stable URL like /sw.js.

The "new version available" prompt

The pattern most real apps settle on: the new version installs in the background and waits. The page notices the waiting worker and shows a small, polite message. When the user clicks Reload, the page asks the worker to skip waiting, then reloads once the new worker takes control. The switch and the reload happen together, so no page runs against a mismatched worker.

A wireframe of a mail app: a folder sidebar on the left and three message rows on the right. At the bottom right, a small prompt reads New version available, with a Reload button. A note below says the prompt is announced by screen readers while focus stays where it was, and it stays until clicked; Reload switches the worker and reloads the page.mail.example.comNew version availableReloadannounced by screen readers, focus stays putstays until clicked; Reload switches and reloads
Polite, not pushy: the update waits in the background and the user picks the moment. Clicking Reload posts SKIP_WAITING and reloads on controllerchange.
js
// page
const reg = await navigator.serviceWorker.register("/sw.js");
function offer(worker) {
  showToast("A new version is available.", "Reload", () =>
    worker.postMessage({ type: "SKIP_WAITING" }));
}
if (reg.waiting && navigator.serviceWorker.controller) offer(reg.waiting);
reg.addEventListener("updatefound", () => {
  const next = reg.installing;
  next.addEventListener("statechange", () => {
    if (next.state === "installed" && navigator.serviceWorker.controller) offer(next);
  });
});
let reloaded = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
  if (!reloaded) { reloaded = true; location.reload(); }
});

// sw.js
self.addEventListener("message", (e) => {
  if (e.data?.type === "SKIP_WAITING") self.skipWaiting();
});
The controller check skips the prompt on the very first install, when there is no old version to replace.
  • Don't interrupt. Never reload on your own while the user is typing. Show the prompt and let them choose. If the app has unsaved state, save a draft first.
  • Make it accessible. Put the message in a live region (role="status") so screen readers announce it, don't move focus to it, and don't auto-dismiss it after a few seconds. Make the Reload button a real button reachable by keyboard.
  • Have a fallback. Some users never click. A common rule: if a new version is waiting, the next in-app navigation does a full page load instead of a client-side route change. That picks up the new worker without a prompt.
  • Critical fixes. For a security fix you may call skipWaiting() directly and accept the version-mixing risk, or keep old hashed files on the server for a few weeks so old tabs can still load their chunks.

Failure modes, security, and the kill switch

A buggy page fixes itself on the next deploy. A buggy service worker can block the next deploy from ever arriving, because it sits in front of the request that would deliver the fix. So plan for failure before you ship the first version.

What breaks in production

SymptomUsual causeFix
Users stuck on an old version for weeksWorker file renamed per deploy, sw.js cached by a CDN, or install failing on a 404 in the manifestStable /sw.js URL with no-cache; alert on install errors; track version share.
"ChunkLoadError" after deploysskipWaiting() mixed versions, or old hashed files deleted from the serverPrompt and reload; keep old hashed files for a few weeks.
Error pages or broken images served for dayscache.put() without checking res.ok, or caching opaque responsesOnly store ok responses; for opaque ones, cap and refresh.
Storage grows to hundreds of MBOld version caches never deleted; uncapped runtime caches; opaque paddingDelete in activate; entry and age limits; catch QuotaExceededError and purge.
Videos won't seekA full cached 200 response served to a Range requestDon't cache media, or answer ranges properly (Workbox has a plugin).
A navigation fails with a network error about a redirected responseA cached response with redirected set to true served to a navigation (navigations use redirect mode manual, not follow)Precache the final URL, or copy the body into a new Response before storing it, which drops the redirect flag.
Another user's data after logoutPersonal API responses kept in the Cache APINetwork only for personal data, or delete those caches on logout.

Privacy and security

  • Personal data on disk. Anything in the Cache API stays after the tab closes and after logout, until your code deletes it. On shared computers, that's another person's inbox. Clear user-specific caches on logout, and have the logout response send Clear-Site-Data: "cache", "storage" as a backstop.
  • A worker amplifies XSS. If an attacker can run script on your origin and can get a file of their choice served from it (an upload folder, a JSONP endpoint), they can register a worker that intercepts every future request, long after the XSS is fixed. Never serve user uploads from your app's origin, and keep a strict Content Security Policy. Browsers send Service-Worker: script when fetching a worker script, so the server can refuse requests for files that shouldn't be workers.
  • HTTPS is required for the same reason: a worker injected by a network attacker would persist.
A decision flowchart. A bad worker is live. Question: can the fix wait, meaning users can finish on the old version? If yes, ship the fix through the normal update flow with a reload prompt. If no, ship a kill-switch worker that calls skipWaiting, deletes all caches, and unregisters. Both paths reach users on their next navigation, because the browser checks /sw.js at the same URL, past the HTTP cache.bad worker livecan the fix wait?users can finish on the old versionyesnoship fix + promptnormal update flowship kill switchskipWaiting, delete, unregisternext navigation
Both paths depend on one thing you set up on day one: a stable /sw.js URL served with Cache-Control: no-cache.

The kill switch

Before you ship the first worker, know how you'll remove it. If a bad version goes out, deploy a tiny worker at the same URL that deletes the caches and unregisters itself. Because the browser checks /sw.js on every navigation and skips the HTTP cache for it, this reaches users on their next visit.

js
// sw.js: emergency replacement
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
  event.waitUntil((async () => {
    const names = await caches.keys();
    await Promise.all(names.map((n) => caches.delete(n)));
    await self.registration.unregister();
    const tabs = await self.clients.matchAll({ type: "window" });
    tabs.forEach((tab) => tab.navigate(tab.url));
  })());
});
No fetch handler, so requests go to the network. Here skipWaiting is the right call: you want the broken version gone at once.

Check your understanding · Exercise 6

A webmail app caches /api/messages with stale while revalidate. Alice logs out on a library computer; Bob logs in. What does Bob see first, and what's the right fix? Pick one.

Workbox, real products, and the interview

Everything so far is a few hundred lines of plain code, and every line is a chance for one of the bugs above. Workbox is Google's open source set of libraries that packages these patterns: precaching with a revision manifest, routing, the five strategies, expiration, and the update flow. Most production workers use it or something built on it.

Workbox in practice

js
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from "workbox-precaching";
import { registerRoute, NavigationRoute } from "workbox-routing";
import { NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheableResponsePlugin } from "workbox-cacheable-response";

cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST); // the build replaces this with the manifest

registerRoute(new NavigationRoute(createHandlerBoundToURL("/index.html")));

registerRoute(({ request }) => request.destination === "image",
  new StaleWhileRevalidate({
    cacheName: "images",
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 86400, purgeOnQuotaError: true }),
    ],
  }));

registerRoute(({ url }) => url.pathname.startsWith("/api/"),
  new NetworkFirst({ cacheName: "api", networkTimeoutSeconds: 3 }));
An app shell for every navigation, capped images (opaque ones included, on purpose), and a network-first API with a 3 second timeout.
  • Two build modes. generateSW writes the whole worker from a config: quick, but you can't add custom code easily. injectManifest takes your worker source and only fills in self.__WB_MANIFEST: use it as soon as you need push, custom routing, or messages.
  • Precaching done right. Workbox stores each file with its revision, downloads only changed files on a new version, fetches past the HTTP cache, and handles redirected responses for navigations.
  • The page side. workbox-window wraps registration and fires a waiting event; wb.messageSkipWaiting() sends the message for the reload prompt.
  • Framework wrappers. vite-plugin-pwa and Serwist (used with Next.js and others) wrap Workbox-style builds. Angular ships its own worker, @angular/service-worker, driven by a generated ngsw.json manifest of hashed files, and it keeps each open tab on a consistent app version.

In real products

  • Twitter Lite and Pinterest's mobile site were early, widely documented progressive web apps. Both used a service worker to cache the app shell, so repeat visits start from disk while data comes from the network.
  • Squoosh, Google's image compressor, works fully offline after the first visit: the whole app is precached, and there's no server data at all.
  • Docs and news sites typically use network first for articles with an offline page, and cache first for hashed assets: fresh content, fast repeat visits, no stuck versions.
  • Web mail and chat apps precache the shell, keep messages in IndexedDB rather than the Cache API (so they can query and update them), and use the reload prompt for updates.

Using it in a system design interview

Bring service worker caching up when the question mentions offline use, flaky or slow networks, emerging markets, repeat-visit speed, or "make it feel like an app". Don't add it to every design: for a one-time checkout flow or a dashboard behind a fast office network, HTTP caching and a CDN are enough, and saying so is a good signal.

  • Draw the layers: page, worker, Cache API next to the HTTP cache, then CDN and origin.
  • Say which strategy each request type gets and why: shell precached, hashed assets cache first, feeds network first with a timeout, avatars stale while revalidate, writes network only with a queue.
  • Mention the lifecycle risk: "new versions wait; we show a reload prompt rather than calling skipWaiting blindly, and we keep a kill-switch worker ready".
  • Junior level: know what a worker is and the strategy names. Mid level: map strategies to requests, explain precaching and cleanup. Senior level: update flow, version mixing, quota and opaque padding, privacy on logout, navigation preload, and how you'd monitor version share.

Summary

  • A service worker is a proxy you write that runs in the browser and sees every request from the pages it controls; the Cache API is its storage, with no expiry unless you code it.
  • The lifecycle keeps one tab on one worker version: install, wait while old tabs are open, then activate. skipWaiting() and clients.claim() shortcut it at the cost of mixing versions.
  • Pick a strategy per request: precache or cache first for hashed assets and the shell, network first with a timeout for fresh content, stale while revalidate when one load behind is fine, network only for writes and personal data.
  • Precache a small shell from a build-generated revision manifest inside the worker script, so any change produces a new worker version.
  • Name caches by version, delete old ones in activate, and cap runtime caches; watch out for opaque responses and their quota padding.
  • Ship updates through a "new version available, Reload" prompt that switches the worker and reloads together, and keep /sw.js at a stable, uncached URL.
  • Plan for failure before the first deploy: a kill-switch worker, cleanup on logout, and monitoring of install errors and version share.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Walk me through what happens when you deploy a new version of a site that has a service worker with a precached app shell.

Question 2

Compare cache first, network first, and stale while revalidate. Which would you use for an app's HTML, its JS bundles, and a news feed API?

Question 3

Why is calling skipWaiting() on every install risky, and when is it fine?

Question 4

Users say the app uses hundreds of megabytes of storage. What would you look for?

Question 5

How do you make sure a broken service worker can't strand your users?

Practice it

These design problems lean on service worker caching.

References

Read next