skip to content

A team ships a service worker that answers every request cache-first, including the HTML document at /. After the next deploy, users keep loading the old app even after reloading the page. Explain why, and how you would change the caching strategy.

level: seniorimportance: must knowfreq 62%

answer

  1. stable URL, changing content
  2. the reload goes through the worker too
  3. stale HTML pins the old bundle hashes
  4. route navigations differently from assets
  5. version the cache name per build

basics

~20 s

The document's URL never changes, so cache-first keeps answering it from the stored copy and never sees the new deploy. That old HTML also references the old bundle hashes, pinning the whole app. Fix it by serving navigations network-first and reserving cache-first for hashed URLs.

solid answer

~50 s

Cache-first means "if there is an entry for this URL, return it and do not ask the network". The document lives at a stable URL, so once `/` is in the cache the worker answers from storage forever and the new deploy is never fetched. Worse, that stale HTML names the previous build's hashed bundles, which *are* legitimately cached — so the entire application is frozen at that build, and a normal reload changes nothing because the reload goes through the worker too. Only the browser's own update check for the worker script can break the loop, and only if the worker file itself changed. The fix is to make the strategy follow the URL contract: route navigations (`request.mode === 'navigate'`) to network-first with a cached fallback for offline, keep cache-first for content-addressed assets only, and give the precache a version-stamped name so an activating worker can drop the old generation instead of inheriting it.

go deeper

for a junior

Know that Cache Storage entries never expire by themselves and that a service worker answering cache-first will keep returning the same document until code changes it.

for a middle

Explain the mechanism end to end: stable URL plus cache-first equals a permanent hit, the stale document pins the old hashed bundles, and reloads still route through the worker.

for a senior

Diagnose it from user symptoms, avoid the hard-reload false negative, and prescribe the routed fix — network-first navigations, cache-first only for content-addressed URLs, version-stamped caches.

for a principal

Own the recovery story: how a bad worker generation is retired, what the deploy pipeline guarantees about URL immutability, and what monitoring would have caught users pinned to an old build.

## Why the page is frozen Cache-first has one rule: on a cache hit, return it without touching the network. Applied to `/`, that rule is a trap, because the document's URL is *stable* while its content changes on every deploy. The first visit stores the then-current HTML. Every later visit — including a normal reload, which is an ordinary navigation that the worker intercepts — finds that entry and returns it. The server's new HTML is never requested, so nothing can ever replace it. The second-order effect is the damaging one. Modern builds emit hashed asset URLs, and the document is the thing that names them. Stale HTML asks for `/app.8f3a1c2d.js`, which really is immutable and really is in the cache, so every subresource resolves happily. The app is internally consistent and completely obsolete. Nothing in the page reports an error, which is why the bug is usually diagnosed from user reports rather than telemetry. A note on reproducing it: in Chromium, a shift-reload ("hard reload") bypasses the service worker entirely, so it looks like the bug fixed itself while every real user stays stuck. Debug with a normal reload, or by inspecting Cache Storage in DevTools. ## What can and cannot break the loop The browser checks the worker *script* for updates independently of this cache, so shipping a changed worker file is the escape hatch — but only if that new worker also stops serving the stale document and clears the old entry. If the deploy changed nothing but application code, the worker script may be byte-identical and no update happens at all. ## The fix Strategy per route, keyed on what the URL promises: ```js self.addEventListener('fetch', (event) => { if (event.request.mode === 'navigate') { event.respondWith( fetch(event.request).catch(async () => (await caches.match('/offline.html')) || Response.error() ) ); } }); ``` - **Navigations → network-first.** The document is fetched fresh whenever the network allows, and falls back to a cached shell or offline page when it does not. Stale-while-revalidate is a defensible alternative if you accept one load of lag and tell the user an update is ready. - **Hashed assets → cache-first.** Content-addressed URLs are the only ones where "never ask again" is true by construction. - **Everything unhashed → network-first or leave it un-routed.** A request the worker does not answer behaves as if no worker existed. ## Cache versioning as insurance Name the cache after the build (`precache-v42`) rather than mutating one long-lived cache. A new worker generation then writes into a fresh cache and deletes the ones that are not its own, so a bad generation cannot outlive its deploy: ```js caches.keys().then((names) => Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n))) ); ``` ## The lesson to state out loud A service worker is deployed code that keeps running after you stop deploying to it. Cache-first on a mutable URL is not a slow-update bug; it is a permanent one, recoverable only through the worker's own update path. That asymmetry — assets are safe to freeze, entry points never are — is what the interviewer is checking you have internalised.

  • Why does the problem persist even though the origin sends Cache-Control: no-cache on the HTML?
    Those headers govern the browser's HTTP cache, which sits *behind* the service worker. A cache-first handler resolves from Cache Storage before any HTTP caching rules are consulted, and entries in Cache Storage do not expire on their own. Only the worker's code decides when that entry is replaced.
  • A colleague says the bug is gone because a hard reload shows the new build. What do you tell them?
    In Chromium a hard reload deliberately bypasses the service worker for the navigation and its subresources, so it tests the server, not the worker. Real users doing an ordinary reload still get the cached document. Verify with a normal reload and by inspecting the entries in Cache Storage.
  • Would stale-while-revalidate on navigations have avoided this?
    Largely, yes — the fresh document would land in the cache on every visit, so users would be at most one load behind rather than permanently frozen. The cost is that the first load after a deploy still shows the previous build, so pair it with a message to the page offering a refresh when the revalidation stores something new.

saying these in an interview costs you the question

  • Blames the CDN or Cache-Control headers on the HTML
  • Says a normal reload always bypasses the service worker
  • Proposes shorter cache expiry, which Cache Storage does not have
  • Keeps cache-first and adds a timestamp check by hand
  • Treats hashed bundles and the document as the same asset class

context