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-errordirective 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.
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-Controland friends. Used by the service worker's ownfetch()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.
// 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));
});
}
- HTTPS only. Service workers need a secure context, because a worker that can rewrite every response is too dangerous over plain HTTP.
localhostis 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.jscan control the whole site./static/js/sw.jscan only control URLs under/static/js/, which is almost never what you want. Serve the worker from the root, or send theService-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.
skipWaiting() is the only way to jump it.The phases one by one
- Download and parse. The browser fetches the script and runs its top level. Syntax errors stop everything here.
- Install. The browser fires an
installevent once per version. This is where you precache the app shell. If the promise you pass toevent.waitUntil()rejects, install fails and this version is thrown away. The old worker, if any, keeps running. - 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.
- Activate. Once nothing uses the old version, the new one gets an
activateevent. This is the safe moment to delete old caches, because no page needs them anymore. - Active and controlling. The worker now receives
fetchevents 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. - Redundant. A worker that failed to install, or was replaced by a newer one, is discarded.
// 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))
)
)
);
});
Shipping worker v2 to a tab that runs v1Step 1 of 6: v1 in charge
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?
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.
activate handler deletes them.The methods that matter
| Method | What it does | Common 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 aPOSTrequest. 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
Responseis 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 thenreturn res. - Matching uses the URL and, by default,
Vary. If the stored response hasVary: Accept-Languageand the new request has a different language, it won't match. OptionsignoreSearch,ignoreMethod, andignoreVaryrelax the rules. - Headers don't apply. A stored response with
Cache-Control: max-age=60is 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.
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
});
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.
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.
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"));
}
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.
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
POSTrequests, payments, analytics beacons, and anything personal that must not sit on disk. In plain code, simply don't callrespondWith(). - 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.
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.
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
| Request | Strategy | Why |
|---|---|---|
| App shell HTML (SPA) | Precached, cache only, updated by new versions | Instant start; the worker version decides which shell you get. |
| Server-rendered pages | Network first, 2 to 4 s timeout, offline page fallback | Content changes often; stale beats a white screen. |
| Hashed JS, CSS, fonts | Cache first (or precache) | The URL changes when the content does, so a cached copy is never wrong. |
| Unversioned images, avatars | Stale while revalidate, capped at about 100 entries | Instant, and refreshed in the background. |
| Read APIs (feed, catalogue) | Network first, or stale while revalidate if one load behind is fine | Depends on how bad stale is. |
Writes (POST, PUT, DELETE) | Network only, plus a queue in IndexedDB if offline writes matter | The Cache API can't store them. |
| Auth, payments, analytics | Network only | Never on disk; never served stale. |
Decision How should the worker answer navigation requests (the HTML page)?
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?
Nothing good. A worker can't run before it is installed, and it can't be installed without the network. On a true first visit the browser shows its normal offline error, worker or not.
Service worker caching improves every visit after the first. That's why precaching at install matters: it fills the cache during the first online visit, so the second visit works offline even for pages the user never opened.
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.
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" })toaddAll(), 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-v42for the precache,imagesandpagesfor 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.
Navigation preload
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.
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.
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, servesw.jswithCache-Control: no-cacheso CDNs and proxies don't hold an old copy. - Never rename the worker file per deploy. If page version 1 registers
/sw-v1.jsand that page is itself served from the cache bysw-v1.js, the page never learns aboutsw-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.
SKIP_WAITING and reloads on controllerchange.// 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();
});
- 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
| Symptom | Usual cause | Fix |
|---|---|---|
| Users stuck on an old version for weeks | Worker file renamed per deploy, sw.js cached by a CDN, or install failing on a 404 in the manifest | Stable /sw.js URL with no-cache; alert on install errors; track version share. |
| "ChunkLoadError" after deploys | skipWaiting() mixed versions, or old hashed files deleted from the server | Prompt and reload; keep old hashed files for a few weeks. |
| Error pages or broken images served for days | cache.put() without checking res.ok, or caching opaque responses | Only store ok responses; for opaque ones, cap and refresh. |
| Storage grows to hundreds of MB | Old version caches never deleted; uncapped runtime caches; opaque padding | Delete in activate; entry and age limits; catch QuotaExceededError and purge. |
| Videos won't seek | A full cached 200 response served to a Range request | Don't cache media, or answer ranges properly (Workbox has a plugin). |
| A navigation fails with a network error about a redirected response | A 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 logout | Personal API responses kept in the Cache API | Network 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: scriptwhen 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.
/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.
// 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));
})());
});
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
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 }));
- Two build modes.
generateSWwrites the whole worker from a config: quick, but you can't add custom code easily.injectManifesttakes your worker source and only fills inself.__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-windowwraps registration and fires awaitingevent;wb.messageSkipWaiting()sends the message for the reload prompt. - Framework wrappers.
vite-plugin-pwaand Serwist (used with Next.js and others) wrap Workbox-style builds. Angular ships its own worker,@angular/service-worker, driven by a generatedngsw.jsonmanifest 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()andclients.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.jsat 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.
The build produces new hashed files and a new precache manifest inside sw.js, so the worker script's bytes change. On a user's next navigation the browser fetches /sw.js past the HTTP cache, sees the difference, and installs the new version: it downloads the changed shell files into a new versioned cache. Install is all or nothing.
The new worker then waits while any tab still uses the old one. The page detects the waiting worker and shows "A new version is available, Reload". On click it posts a message, the worker calls skipWaiting(), the page reloads on controllerchange, and in activate the worker deletes the old version's cache.
I'd also mention the risks: never rename the worker file, keep old hashed files on the server for a while for tabs that haven't reloaded, and track the share of sessions on the latest version.
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?
Cache first returns the stored copy without asking the network: zero latency, but it never refreshes, so it only suits URLs whose content never changes, like hashed bundles. Network first asks the network and falls back to the cache on failure or after a timeout: fresh when online, still works offline. Stale while revalidate returns the stored copy at once and refreshes it in the background: instant, but one load behind.
Hashed JS: cache first or precache. HTML: for a client-rendered app, the precached shell; for server-rendered pages, network first with about a 3 second timeout and an offline page, plus navigation preload. News feed: network first with a timeout, or stale while revalidate with a "new stories" signal if one load behind is acceptable.
Question 3
Why is calling skipWaiting() on every install risky, and when is it fine?
It activates the new worker while tabs loaded with the old version are still open. Those tabs run old JavaScript against the new worker and its caches. If the old page lazily asks for a chunk that the new version deleted, or the server no longer has, the user gets a chunk load error.
It's fine when the worker holds no version-specific files (only runtime caching of hashed assets that stay on the server), for an emergency kill switch, or when it happens together with a page reload because the user clicked a prompt.
Question 4
Users say the app uses hundreds of megabytes of storage. What would you look for?
First, old version caches that were never deleted: each deploy adding a full copy of the shell. Clean up in activate by deleting caches with your prefix that aren't current. Second, uncapped runtime caches for images or API responses: add entry and age limits.
Third, opaque responses from cross-origin requests without CORS. Browsers pad their size in quota accounting, in Chrome by about 7 MB each, so a hundred small icons look like hundreds of MB. Serve those assets with CORS or stop caching them. I'd check with navigator.storage.estimate() and the DevTools Cache Storage view.
Question 5
How do you make sure a broken service worker can't strand your users?
Keep the worker at one stable URL, serve it with Cache-Control: no-cache, and keep the worker small so failures are rare. Have a kill-switch worker ready: it skips waiting, deletes all caches, unregisters itself, and reloads open tabs. Deploying it to the same URL reaches users on their next navigation.
For a wider reset, the server can send Clear-Site-Data with "storage". And monitor: log install errors and the version each session runs, so you notice a stuck rollout within hours, not weeks.
Practice it
These design problems lean on service worker caching.
References
- MDN: Service Worker API Lifecycle, registration, and the fetch event.
- MDN: Cache Every Cache API method and its matching options.
- web.dev: The service worker lifecycle Waiting, skipWaiting, clients.claim, and updates in detail.
- Chrome for Developers: Workbox Strategies, precaching, plugins, and workbox-window.
- web.dev: Speed up service worker with navigation preloads Why worker start-up delays navigations and how preload fixes it.
- W3C: Service Workers specification The normative source for the lifecycle and the Cache API.
Read next
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.
- Service workersA script the browser runs beside your pages, with no DOM and no memory it can trust, that wakes up for events (fetch, message, push, sync), handles them, and is shut down when idle: a programmable network proxy with sharp limits.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.