Intercepting Fetch
The worker sits between the page and the network and can answer from cache, from the network, or with a response it invents — which is exactly why it can brick your site.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Once a service worker controls my page, what actually happens to a request — and what happens if my handler is wrong?
A person clicks a link on a flaky connection. They expect the app to appear, not a dinosaur — and if something genuinely cannot load, they expect to be told, not to stare at a blank frame.
Add a fetch listener, call event.respondWith(caches.match(event.request).then((r) => r || fetch(event.request))), and everything is now offline-capable.
That one line makes every cached URL permanently frozen for that user. A returning visitor gets the cached copy first, forever, including the copy of index.html from the build with the bug in it.
- That one line makes every cached URL permanently frozen for that user. A returning visitor gets the cached copy first, forever, including the copy of
index.htmlfrom the build with the bug in it. - It intercepts requests you never intended to handle:
POSTs, analytics beacons, range requests for video, cross-origin third-party scripts, and the browser's own requests for the manifest. - A
caches.matchon aPOSTrequest never matches, so every mutation falls through tofetch— until the network is gone, at which pointrespondWithrejects and the browser shows a network error for a request your app was ready to handle (The Offline Mutation Queue). - A handler that throws synchronously inside
respondWithturns a request that would have succeeded into a failed one. The worker has made the site *worse* than having no worker. - And it persists. Ship that worker, realise the problem, ship a fix — and the users who need the fix are the ones whose browsers are being answered by the broken worker.
What is actually happening
In the browser, not in the framework.
- Once a document is controlled, every request it makes in scope fires a
fetchevent in the worker: navigations, subresources,fetch()calls,XMLHttpRequest, images, even the request for the page itself. event.respondWith(responseOrPromise)tells the browser "I will answer this". Whatever you resolve with becomes the response the page sees. You may resolve with a cachedResponse, a networkResponse, or anew Response(body, init)you constructed from nothing.- Not calling `respondWith` is a first-class choice. If the handler returns without calling it, the browser performs the request normally. This is the correct default for everything you have not deliberately decided about.
- If you call
respondWithand the promise rejects, the page gets a network error — even if the network was fine. Interception converts your bugs into failed requests. - The worker's own outbound
fetch()calls do not re-enter itsfetchhandler, so there is no recursion; but they are subject to CORS exactly as the page's would be, and an opaque cross-origin response has an unreadable status and body. Cache Storageis a separate store from the HTTP cache: keyed byRequest, controlled entirely by your code, and not evicted by HTTP freshness rules (Cache Storage).- Navigation preload (
registration.navigationPreload) lets the browser start the navigation request in parallel with worker startup, so a cold worker start does not sit in front of the network round trip. - The escape hatch is
registration.unregister()plus clearing caches. A worker can be written whose only job is to remove itself — and it can be deployed at the same URL as the broken one.
What this makes the browser do
And which of it is avoidable.
- Starting the worker if it is not running, before the first request it must answer. On a cold start this is script evaluation on the critical path of a navigation.
- Dispatching an event per request. On a page with hundreds of subresources, that is hundreds of trips through your JavaScript before anything reaches the network stack.
- Matching requests against Cache Storage — a keyed lookup over a disk-backed store, not a memory hash.
ignoreSearchandignoreVaryoptions change what matches and what does not. - Cloning response bodies. A
Responsebody is a stream that can be read once, so caching a response you also return requiresresponse.clone()*before* either is consumed. - Falling back: if
respondWithis never called, all the above still happened — the event dispatch cost is paid even for requests you pass straight through.
Three things a handler can do
The mental model is small: for each request the worker either stays out of the way, answers from something it already has, or manufactures a response. The danger comes from the third option being available at all — you can return a 200 with any body you like for any URL on your origin, and the page has no way to tell.
That is also the power. An offline fallback page, a synthesised JSON error your data layer already knows how to render, a queued-mutation acknowledgement — all of these are responses the network never produced (Loading, Error, Empty — The States You Did Not Render).
1self.addEventListener('fetch', (event) => {2 const req = event.request3 4 // 1. Opt out loudly. Mutations, cross-origin and non-GET are not ours.5 if (req.method !== 'GET') return6 if (new URL(req.url).origin !== self.location.origin) return7 8 // 2. Decide by *kind*, not by URL string.9 if (req.mode === 'navigate') {10 event.respondWith(handleNavigation(event))11 return12 }13 if (req.destination === 'script' || req.destination === 'style') {14 event.respondWith(cacheFirst(req))15 }16 // everything else: no respondWith, browser behaves normally17})18 19async function handleNavigation(event) {20 try {21 // Use the parallel request the browser already started, if it exists.22 const preloaded = await event.preloadResponse23 if (preloaded) return preloaded24 return await fetch(event.request)25 } catch {26 // The floor: never reject out of respondWith.27 const cached = await caches.match(event.request)28 return cached || caches.match('/offline.html')29 }30}The two early returns are the most valuable lines in the file. Every request you do not intercept is a request your worker cannot break.
How a worker bricks a site
This is the section that justifies the module. A bad deploy of a normal bundle is recovered by the next deploy: the browser fetches the new files and the problem is gone. A bad deploy of a service worker is recovered only if the broken worker still lets the browser update it — and the ways it can fail to do that are ordinary mistakes.
The pattern in every row is the same: the worker answered instead of failing over. That is what makes it different from a normal outage, where the user retries and eventually gets through.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Cache-first on the navigation request with no revalidation | Returning users pinned to one build indefinitely | Cache Storage never expires; the HTML that names every asset is frozen | Network-first (or stale-while-revalidate) for navigations; cache-first only for content-hashed URLs (Caching Strategies). |
| Handler throws on an unexpected request type | Images, media or extension requests fail on controlled pages only | respondWith was called and the promise rejected, so the browser reports a network error | Guard by method, origin and destination; wrap every strategy in a catch that returns something. |
| Cached an opaque cross-origin response | A CDN error is served as if it were the asset, forever | Opaque responses have status 0 — you cannot tell success from failure before caching | Only cache responses you can read; for cross-origin, request with cors mode or do not cache. |
Response cached and returned without clone() | Blank page or truncated asset, intermittently | A response body is a single-use stream | cache.put(req, res.clone()) before returning res. |
| Authenticated API responses in a shared cache | The next user on a shared device sees the previous user's data | Cache Storage is scoped to the origin, not to the session | Do not cache them, or key by identity and delete on logout (Storage Security and Durability). |
| Worker script itself served from a long-lived cache | The fix cannot be deployed; every user is stuck | The update check never sees new bytes | The reason the kill switch has to exist and has to be tested before you need it. |
The kill switch is a design requirement
registration.unregister() and clients.matchAll()/client.navigate() are specified and widely implemented; what differs is the timing of the next update check, so a kill switch reaches users on the browser's schedule rather than yours.Treat this as part of the first release, not as an incident-response document. It is a complete service worker whose only behaviour is to remove itself, delete every cache, and reload the open clients so they come back uncontrolled. You deploy it to the same URL as the broken worker.
It needs skipWaiting(), because otherwise it inherits the exact problem you are trying to escape: waiting behind the clients of the worker you are trying to kill. And it needs to navigate the clients, because unregistering does not stop the currently-controlling worker from serving the pages that are already open.
// Incident doc, step 4: // "Ask affected users to open devtools, // Application > Storage > Clear site data, // then hard reload."
// sw.js — deploy this at the SAME url as the broken worker
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
await self.registration.unregister()
const keys = await caches.keys()
await Promise.all(keys.map((k) => caches.delete(k)))
const clients = await self.clients.matchAll({ type: 'window' })
for (const client of clients) client.navigate(client.url)
})())
})
// No fetch handler at all: nothing is intercepted while this runs.The first plan requires reaching users you cannot identify and asking them to perform a technical task. The second is a normal deploy that takes effect on the next update check, and it is the only rollback that works for a general audience. The absence of a fetch handler is deliberate — a worker that intercepts nothing cannot make the incident worse.
How to build it
Most important first.
- Handle the smallest set of requests you can justify and let everything else fall through. An early
if (event.request.method !== 'GET') returnremoves an entire class of bugs. - Decide per request *kind*, not per URL. Navigations, static hashed assets, API reads and API writes want different strategies, and
event.request.mode === 'navigate'anddestinationtell you which is which (Caching Strategies). - Never let a handler reject without a fallback. Wrap the strategy so that the worst case is a cached page, an offline page, or a synthesised
Responsethat your app understands — not a browser network error. - Design the kill switch before the first release. A worker whose install unregisters itself and deletes every cache is a rollback you can deploy in minutes; without it, your only rollback is asking users to clear site data.
- Enable navigation preload if you intercept navigations at all, and use the preload response when you have one. Otherwise a cold worker start is pure added latency for the most important request on the page.
- Keep the worker's logic small, pure and testable. It runs where you cannot see it, in a context you cannot debug over the shoulder of a user on a train.
- Synthesise responses honestly: an offline fallback should return a real HTML document with a real status, not a 200 pretending the API answered (Offline UX).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A synthesised offline page is a real page and owes real semantics: a heading, a landmark, focus placed somewhere sensible, and a retry control that is a
button(Semantics Are Behaviour). - Serving a stale cached page gives assistive technology no signal at all — the content simply is what it is. If what the user is reading may be out of date, the page must say so in text, not with a grey dot (Offline UX).
- When a navigation is answered from cache and later replaced with fresh content, that swap must be announced politely rather than silently re-rendering under a screen reader's reading position (Live Regions and Announcement).
- A request that fails because of your handler produces a generic browser error page with none of your app's navigation or skip links. Any interception path that can dead-end is an accessibility failure as well as a UX one.
What can go wrong
- The bricked site: a worker caches
/index.htmlunder a cache-first rule and never revalidates. Returning users are pinned to one build until they clear storage — which is not something you can ask a general audience to do. - A handler that throws on an unexpected
Request(ablob:URL, a chrome extension request, a range request) and turns a working page into a broken one. - Caching an opaque cross-origin response and later serving it: the page cannot read the status, so a cached CDN error looks identical to a cached success.
- Caching a response you also return without cloning it. The body is consumed once; one of the two consumers gets an empty stream.
- Caching authenticated responses. Cache Storage is per-origin, not per-user; a shared device now serves one user's data to the next (Storage Security and Durability).
- Storage quota exceeded during a cache write, throwing inside a handler that had no rejection path.
- The mitigation failing: a kill-switch worker that never activates because it, too, has to wait for the old worker's clients to close. It must call
skipWaiting().
- A cached response and a network response for the same URL can both be in flight; whichever your strategy resolves first is what the user sees, and the other may still write to the cache afterwards (Out-of-Order Responses).
- A cache write racing an
activatecleanup: a response written just as the new worker deletes old caches lands in a cache that no longer exists. - The worker being terminated mid-
waitUntilwhile a background cache update is still writing, leaving a partially populated cache. - A kill-switch worker racing the broken worker's own update check — the fix only lands if the browser is still willing to re-fetch the script.
- The worker sees every request in scope, including credentials-bearing ones, and can rewrite every response body. That is the definition of a man in the middle — you have just installed one on your own origin on purpose.
- Because it is same-origin and persistent, a worker installed by a successful XSS outlives the injected script and can keep serving attacker-controlled HTML for your URLs after the vulnerability is patched (Cross-Site Scripting).
- Do not cache responses to authenticated requests in a shared cache unless the cache key includes the identity, and clear those caches on logout (Session Expiry and the Refresh Race).
- Requests the worker makes are still subject to CORS, and an opaque response tells you nothing. Never treat "the fetch resolved" as "the origin server said yes" (CORS).
- The worker script itself is the highest-integrity asset you ship. It should be covered by your CSP, your subresource pipeline and your deploy review with more care than any page bundle (Content Security Policy).
- "The worker only affects requests I wrote
fetch()for." It affects every request the document makes in scope, including the navigation itself. - "If my handler fails, the browser falls back to the network." It does not. Once you call
respondWith, the outcome of your promise *is* the response. - "I can fix a bad worker by deploying a good one." Only if the bad one lets the new script through and the update path works. That is exactly what a bad worker breaks.
- "Cache Storage respects my HTTP cache headers." It does not. Nothing expires unless you delete it (Browser HTTP Caching).
- "Serving from cache is always faster." A large disk-backed cache lookup on a slow device is not free, and cache-first on a fast network can be slower than the network for small responses.
Measuring it, and what changes in the field
- The Network panel marks worker-served responses distinctly and shows a second row for the worker's own outbound request, so you can see exactly where a response came from (Debugging the Network).
- The Application panel lets you inspect Cache Storage entry by entry — the only reliable way to see that you cached an error response.
- Error tracking should tag whether the session was controlled by a worker. A failure rate that differs between controlled and uncontrolled sessions is your handler (Frontend Error Tracking).
- Watch the rate of synthesised offline responses in the field. A rise means either a real outage or a handler that is failing over when it should not (Network Failures Only the Client Can See).
- On a flaky connection — connected but useless —
fetchmay hang rather than reject. A strategy with no timeout waits forever, and the user sees nothing at all (Retries, and the Duplicate Order). - On a cold worker start, script evaluation precedes the first response. On a slow device that startup is measurable, which is what navigation preload is for.
- Under storage pressure the browser may evict Cache Storage between visits, so every read must tolerate a miss.
- With a large precache,
caches.matchover many entries is slower than on a small one, and the first navigation after activation is the worst case.
- Interception buys offline capability and instant repeat loads, and costs you an event dispatch and a piece of hard-to-observe production logic on every single request.
- A conservative worker that passes most requests through is safer and gives up most of the offline story. That is often the right first release.
- A kill switch is code you hope never to run, kept working by testing you have to remember to do. It is still worth it.
- Navigation preload removes cold-start latency and means the network request is made even when your handler was going to answer from cache — wasted bytes on a metered connection.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALInterception semantics —
respondWithoverriding the response, no call meaning default behaviour, a rejection producing a network error — are specified and consistent across Chromium, Gecko and WebKit. - BROWSER-SPECIFICNavigation preload is not implemented everywhere: Chromium and Firefox support
registration.navigationPreload, WebKit historically has not, so a design that depends on preload to hide cold-start latency must still be correct without it. Feature-detect rather than assume. - SPEC-EVOLVINGBrowsers cap or bypass HTTP caching of the worker script so a bad worker can always be replaced, but the exact cap has changed over time and differs by browser — rely on serving the script with revalidation, not on a specific number.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — a service worker makes each browser a cache replica with its own invalidation policy, and a stale replica you cannot reach from the server is the client-side shape of the same problem.
- — Testing & Reliability Engineering — a rollback that requires user action is not a rollback; the kill switch deserves the same rehearsal a database restore does.