Service workers and the offline cache
A service worker is code in front of the network, not another HTTP cache: you write its answers, and updates wait for old tabs.
Predict first
Before you read: predict
ExamHall's exam page registers a service worker, v1. When it installs, v1 stores the exam shell in a cache named shell-v1, and it answers the exam page from that cache first. A candidate has the exam open in one tab, controlled by v1. At 09:10 ExamHall deploys v2: a changed sw.js that stores the new shell in shell-v2 and deletes the other caches when it activates. At 09:12 the candidate presses reload once.
What does the tab show after the reload?
The 30-second version
A service worker is a script the browser runs apart from your tabs. Each request from a page it controls goes through its fetch handler, which answers from its own cache, the network, or both, by a strategy you write. A changed script installs beside the active one and waits until no tab uses it, unless it skips waiting.
A service worker is code in front of the network, not another HTTP cache: you write its answers, and updates wait for old tabs.
How it works
Step 1 of 16
navigator.serviceWorker.register('/sw.js', { scope }). The worker runs apart from every tab, only in secure contexts (HTTPS, localhost, 127.0.0.1), under its scope.Analogy: An abbey's cellarer and the lay brothers' work parties
A work party that set out under the cellarer on duty draws all it needs through him. He keeps a larder, and sends a cart down the valley road to the market town for anything else.
| At the abbey | In the browser |
|---|---|
| a work party that set out under the cellarer on duty draws everything through him | a controlled page's requests go to the active worker (steps 2–3) |
| the larder keeps whatever the cellarer put there, whatever the date chalked on the crock | the Cache API (steps 5–6) |
| larder first, market first, or the larder now with a cart sent to market as well | the three strategies (steps 8–10) |
| floods close the valley road: the cellarer still works, but every cart he sends turns back | offline (step 11) |
| a newly appointed cellarer fills a larder of his own, then waits while any party that set out under the old one is still out | install and waiting (steps 12–14) |
| the abbot tells the new cellarer to take office at once | skipWaiting() (step 15) |
In real terms: a controlled page asks its worker; the worker answers from its store or the network, by its code.
Breaks down when: a work party knows which cellarer sent its carts; a page cannot tell which worker answered unless it asks.
Examples, run for real
4.1 ExamHall EXH-4580: a reload during a Wi-Fi blip
A Wi-Fi blip at the Leeds centre lasted 40 s. Candidates who reloaded the HIST-104 exam in that time got the browser's offline error; their 12 answers were safe in the IndexedDB outbox, but the page that shows them would not load. MR !4880 adds a worker that stores the exam shell when it installs and answers the exam page cache-first. The tabs below were opened after it went live.
4.1 before (no worker) reload while offline: the browser's offline error (net::ERR_INTERNET_DISCONNECTED)
4.1 MR !4880 reload while offline: shows "HIST-104 attempt" from the worker's cache · 12 answers waiting in the outbox
The shell came from the worker's store (steps 8, 11); the answers came from IndexedDB, which the shell reads.
4.2 Payments ledger: a fee table sent no-store
The ledger's worker answers /api/fees cache-first and stores what the network returns. The server sends the fee table with Cache-Control: no-store. The page loads it, the server raises the card fee, and the page is reloaded and loads it again.
4.2 load 1 the server answered 200 with Cache-Control: no-store · shows "card fee 1.4%" · the worker's cache holds /api/fees: yes (stored header: no-store)
4.2 load 2 the server now says "card fee 1.6%" · requests for /api/fees reaching the server: 0 · shows "card fee 1.4%"
no-store keeps a response out of the HTTP cache, not out of the worker's store (step 6).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A payments ledger's service worker answers /api/fx, the EUR/GBP rate, stale-while-revalidate; its store starts empty, and the page is controlled. The user clicks Refresh rates three times. The server's rate is 0.851 at the first click, 0.853 at the second and 0.856 at the third, and each click's network fetch has finished before the next click.
Which rates do the three clicks show?
Question 2 of 7
The ledger's worker answers /api/statement network-first and /api/fx cache-first, storing what the network returns, and leaves every other request alone. This is its whole fetch handler:
Every response is sent with Cache-Control: no-store. Online, the controlled page requests the statement, the rates and /receipts/881.pdf once each. Then the network drops, and the page requests all three again.
Which of them get a response?
self.addEventListener('fetch', (event) => { const path = new URL(event.request.url).pathname; const save = (res) => caches.open('ledger').then((c) => c.put(event.request, res.clone())).then(() => res); if (path === '/api/statement') { event.respondWith(fetch(event.request).then(save).catch(() => caches.match(event.request))); } else if (path === '/api/fx') { event.respondWith(caches.match(event.request).then((hit) => hit || fetch(event.request).then(save))); }});Question 3 of 7
The ledger's worker answers /api/limits and /api/balance network-first with fetch(event.request), storing each reply. The server sends the limits with Cache-Control: max-age=600 and the balance with Cache-Control: no-store. At 10:00 the controlled page fetches both and shows €500 a day and €2,140. At 10:02 the server changes them to €800 a day and €1,960. At 10:03 the page, online, fetches both again.
What does it show?
Question 4 of 7
The ledger page /ledger/ belongs to build a1 and, when the user clicks Export, lazy-loads /export.a1.js. Worker v1 stored the page and that chunk in ledger-v1 when it installed, and answers every request cache-first from its own cache, falling back to the network. A user has had the ledger open since 09:00. At 10:00 the team deploys build b2: the server now has /export.b2.js and no longer has /export.a1.js, and a changed sw.js (v2) stores the b2 page and chunk in ledger-v2 and calls self.skipWaiting() in its install handler. v2's fetch handler is the same code as v1's, so it reads only its own cache, ledger-v2. At 10:05 the open tab's hourly registration.update() finds v2. The user then clicks Export in that tab.
What happens?
Question 5 of 7
Worker v1 controls one ledger tab, A. At 10:00 the team deploys v2, a changed sw.js that does not call skipWaiting(). At 10:01 the user reloads tab A, at 10:03 opens the ledger in a new tab B, at 10:05 closes tab A and at 10:09 closes tab B.
In what order do these happen?
Your order
- 1empty
- 2empty
- 3empty
- 4empty
- 5empty
- 6empty
Tap the lines in the order you expect.
Question 6 of 7
This is the ledger worker's whole fetch handler:
Online, the controlled page pays an invoice with fetch('/api/payments', { method: 'POST', body }). The server records the payment and answers 201.
What does the page's fetch get, and what does the worker's cache hold afterwards?
self.addEventListener('fetch', (event) => { if (!new URL(event.request.url).pathname.startsWith('/api/')) return; event.respondWith( fetch(event.request) .then((res) => caches.open('api').then((c) => c.put(event.request, res.clone())).then(() => res)) .catch(() => caches.match(event.request)), );});Question 7 of 7
A user opens the ledger's statement page for the first time. The page registers a worker. Its install handler stores the page and /api/statement, its fetch handler answers every request cache-first, and it has no activate handler. Once the worker is active, the user clicks Refresh twice in that tab, each a fetch('/api/statement'), then reloads the tab and clicks Refresh once more.
How many of the three Refresh clicks send a request that reaches the server?
Go deeper
Pro tips
- Drive offline tests from Playwright with
context.setOffline(true), not by stopping your server. It cuts the requests a service worker makes as well as the page's, so the handler's ownfetch()rejects as it would on a train. When: testing any strategy's offline path (step 11). Verify:
Status: RAN.tip context.setOffline(true): the worker's network-first fetch of /api/ping rejected, nothing stored, so the page got TypeError · requests reaching the server: 0 - Shift-reload to look at a page without its worker. web.dev: "If you force-reload the page (shift-reload) it bypasses the service worker entirely. It'll be uncontrolled." When: checking whether a bug is in the worker or the server (steps 2–3). Status: DOC; not run in this session.
Source
quote-check.mjs finds every passage below verbatim in its saved text. Valid as of 2026-10-16.
- W3C, Service Workers (Candidate Recommendation Draft): https://www.w3.org/TR/service-workers/ (
w3c-service-workers.txt). - MDN, Service Worker API: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API (
mdn-service-worker-api.txt). The row's second source. Quoted: "Service workers are only available in secure contexts : this means that their document is served over HTTPS, although browsers also treat http://localhost as a secure context, to facilitate local development." and "Your service worker can respond to requests using the FetchEvent event. You can modify the response to these requests in any way you want, using the FetchEvent.respondWith() method." Supports steps 1 and 3. - web.dev, The service worker lifecycle (Jake Archibald): https://web.dev/articles/service-worker-lifecycle (
webdev-sw-lifecycle.txt, fetched with?hl=en). The row's third source. Quoted: "Your service worker is considered updated if it's byte-different to the one the browser already has." · "Most browsers, including Chrome 68 and later , default to ignoring caching headers when checking for updates of the registered service worker script." · "If you force-reload the page (shift-reload) it bypasses the service worker entirely. It'll be uncontrolled." Supports steps 2, 12–15 and tip 2. - MDN, Cache (an MDN page beside the row's second source): https://developer.mozilla.org/en-US/docs/Web/API/Cache (
mdn-cache.txt). Quoted: "Items in a Cache do not get updated unless explicitly requested; they don't expire unless deleted." Supports step 6.
The names cache-first, network-first and stale-while-revalidate are the curriculum row's; none of the three sources defines them, so steps 8–10 describe each as the handler code lib/sw-src.mjs runs, and no key rests on a definition. Step 7 (the worker's fetch() through the HTTP cache) and the offline behaviour of step 11 are observed in Chromium, not quoted.
Related lessons
Checked against the tools and docs as of 2026-10-16.