Service workers, caching, and offline support
Intercept requests with a service worker and choose a caching strategy per resource to support offline use without serving stale data.

WHAT YOU WILL BE ABLE TO DO
Learning outcomes
- Explain service workers, caching, and offline support in plain language.
- Connect the behavior to the underlying browser or framework model.
- Implement the core pattern and reason through edge cases.
- Answer common follow-ups without relying on memorized phrases.
01Explain it simply
A service worker is a script the browser runs separately from any page, sitting between your app and the network, that can intercept requests and decide whether to answer from a cache, the network, or both — which is what makes offline support and instant repeat loads possible.
One-line definition: Intercept requests with a service worker and choose a caching strategy per resource to support offline use without serving stale data.
02Mental model
A service worker runs on its own thread with no DOM access, goes through an install, activate, and idle/fetch lifecycle independent of any one page, and can outlive the page that registered it. Once active, it can intercept every fetch event from pages under its scope and choose a strategy: cache-first, serving from the Cache API and falling back to the network; network-first, trying the network and falling back to cache; or stale-while-revalidate, serving the cache immediately and updating it in the background for next time.
03Step by step
- Register the service worker from the page, scoped to the routes it should control.
- On install, open a named cache and pre-cache the app shell needed to boot offline.
- On activate, delete old cache versions so stale assets don't linger.
- In the fetch handler, choose a strategy per request type — cache-first for the app shell, network-first or stale-while-revalidate for data that should stay current.
- Version the cache name so a new deploy can safely replace old entries.
- Handle the update lifecycle deliberately, with skipWaiting/clients.claim or a user-facing refresh prompt, instead of leaving users on a stale worker indefinitely.
04Working example
// sw.jsconst CACHE = "shell-v3";self.addEventListener("install", (event) => { event.waitUntil(caches.open(CACHE).then((c) => c.addAll(["/", "/app.js", "/app.css"])));});self.addEventListener("activate", (event) => { event.waitUntil( caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) );});self.addEventListener("fetch", (event) => { event.respondWith(caches.match(event.request).then((cached) => cached ?? fetch(event.request)));});install pre-populates one named cache with the app shell; activate clears any previous cache version so an old deploy can't leak stale files; fetch answers from that cache on a match and only touches the network otherwise — a minimal cache-first strategy suitable for a static shell, not for data that must stay fresh.
05Where it is used
- Installable, offline-capable web apps (PWAs)
- Instant repeat page loads by serving the app shell from cache
- Background sync of writes made while offline
- Push notifications, which require an active service worker registration
06Common mistakes
- Cache-first for API or data endpoints, which serves stale data indefinitely instead of ever refetching
- Forgetting to bump the cache name or version, so activate never clears old assets and storage grows unbounded
- Assuming a new service worker takes over immediately, when by default it waits until every tab using the old one is closed
- Caching opaque cross-origin responses without realizing their status can't be inspected, only replayed
07Interview answer
Name the specific caching strategy per request type instead of saying 'it caches things' — cache-first, network-first, and stale-while-revalidate solve different freshness and availability trade-offs, and applying the wrong one to a resource is the actual failure mode being probed for.
A user reports seeing old content after a deploy fixed it, while the service worker's fetch handler is cache-first for everything. What's the likely cause, and the fix?
The cache-first strategy is serving a previously cached response indefinitely because the cache version wasn't bumped and the new worker may not have activated yet; the fix is versioning the cache name per deploy, deleting old versions on activate, and deciding deliberately how new workers take control rather than leaving it to the default wait-for-tabs-to-close behavior.
DDConcept deep dives
Deep dive 1
A service worker sits in front of the network, not in front of the DOM
Unlike a regular script tag, a service worker runs on a separate thread with no access to window, document, or any page's DOM; what it does have is the ability to intercept every fetch request from pages within its registered scope, and to keep running, and receive events like push, even when no tab using it is open. That combination — network interception without page access, and a lifetime independent of any single page — is what makes it the right place to implement caching and offline behavior, and the wrong place to try to manipulate UI directly.
- Communication with a controlled page happens through postMessage or the Clients API, not direct references.
- A service worker can be evaluated and running before any page that uses it has finished loading.
- Its scope, the set of URLs it controls, is determined by default by where the script file is served from.
Deep dive 2
Caching strategy is a per-resource decision, not a global setting
Cache-first, network-first, and stale-while-revalidate all answer the fetch handler's core question, cache or network and in what order, differently, and a real application typically needs more than one of them active at once, branched by request type inside a single fetch listener. The static app shell fits cache-first; live data that should be current when possible fits network-first or stale-while-revalidate. Applying one strategy to every request either serves stale data indefinitely or defeats the point of caching for offline use.
- Branch the fetch handler's strategy on the request's URL or destination, not one global rule.
- Never cache-first anything where staleness has real consequences, such as pricing or account balances.
- Stale-while-revalidate is a good default for data where fast now and correct soon is an acceptable trade-off.
Deep dive 3
The update lifecycle needs a deliberate policy, not the default left in place
By default, a newly activated service worker doesn't take over pages that are already open, and a page keeps running against whichever worker version it started with — this avoids a page suddenly having its in-flight resources served by different code mid-session, but it also means users can sit on a stale version indefinitely if they never fully close and reopen the app. Calling skipWaiting() and clients.claim() forces immediate takeover, which needs to be paired with detecting the change so the page can reload cleanly, or it can serve a version-mismatched app.
- Version the cache name per deploy and delete old versions in activate, unconditionally.
- Decide explicitly between prompting the user to refresh and forcing takeover plus reload — don't leave it implicit.
- Test the update path itself, not just the fresh-install path; most production bugs live in the transition between versions.
QAInterview questions and model answers
Attempt each answer aloud before opening it. The model answer shows the depth and precision expected in an interview; it is not a script to memorize.
Intermediate · Conceptual · 1 min · Question 1What can a service worker access that a regular page script cannot, and what can't it access that a page script can?Open model answer
Model answer
It runs on its own thread separate from any page and persists independently of a page's lifetime, letting it intercept network requests and receive push events even when no tab is open. In exchange, it has no access to the DOM and can't directly read or manipulate a page; communication with pages happens through postMessage or the Clients API.
Open question page →Advanced · Conceptual · 1 min · Question 2Walk through the service worker lifecycle from registration to controlling a page.Open model answer
Model answer
The page calls navigator.serviceWorker.register(), which downloads and evaluates the worker script and fires its install event, typically used to pre-cache assets. Once install succeeds, the worker waits until no page is still controlled by a previous worker, then fires activate, typically used to clean up old caches. Only after activation does it begin controlling pages and intercepting their fetch events — already-open pages stay on the previous worker unless the new one calls clients.claim().
Open question page →Intermediate · Conceptual · 1 min · Question 3What's the practical difference between cache-first, network-first, and stale-while-revalidate?Open model answer
Model answer
Cache-first checks the cache and only goes to the network on a miss, favoring speed and offline availability over freshness, appropriate for a static app shell. Network-first tries the network first and falls back to cache on failure, favoring freshness while staying usable offline. Stale-while-revalidate returns the cached response immediately for speed, then fetches in the background to update the cache for next time.
Open question page →Intermediate · Conceptual · 1 min · Question 4Why is deleting old cache versions during the activate event important?Open model answer
Model answer
Each deploy that changes the cache name creates a new, separate cache; if old caches are never deleted, they accumulate indefinitely in the user's storage and can be accidentally matched by a caches.match() call that isn't scoped to the current cache name, serving stale assets alongside current ones.
Open question page →Advanced · Conceptual · 1 min · Question 5Why doesn't a newly activated service worker automatically take over pages that are already open?Open model answer
Model answer
By default, a page stays controlled by whichever worker was controlling it when it loaded, so a page's already-loaded resources aren't suddenly served by different code mid-session; a new worker only starts controlling existing pages after they're fully closed and reopened, unless it explicitly calls clients.claim(), often paired with skipWaiting() in install, to take over immediately.
Open question page →Advanced · Conceptual · 1 min · Question 6What's the risk of calling skipWaiting() unconditionally on every deploy?Open model answer
Model answer
It forces the new worker to activate immediately and take over open tabs, which can serve page HTML from one version alongside JS or CSS from a newer cached version if the page doesn't also reload, producing version-mismatch bugs; many apps instead prompt the user to refresh once an update is detected.
Open question page →Intermediate · Conceptual · 1 min · Question 7How would you keep an offline-capable app's API data reasonably fresh without losing offline access to it?Open model answer
Model answer
Use network-first or stale-while-revalidate for those requests rather than cache-first: network-first tries live data and only falls back to the last cached response when offline, while stale-while-revalidate shows the cached response instantly and refreshes it in the background, so the app is never blocked on the network but isn't stuck on indefinitely stale data.
Open question page →Advanced · Conceptual · 1 min · Question 8Can a service worker cache a response from a different origin, like a CDN-hosted font or image?Open model answer
Model answer
Yes, but a cross-origin response without CORS is stored as an opaque response — the cache can replay it, but JavaScript can't inspect its status code or body, so a failed request such as a 404 can be cached and replayed as if it succeeded; requesting the resource with CORS mode when possible avoids that blind spot.
Open question page →Beginner · Conceptual · 1 min · Question 9What's required for a web app to be installable as a PWA, beyond just having a registered service worker?Open model answer
Model answer
A web app manifest describing the app's name, icons, and display mode, served over HTTPS, alongside a service worker that at minimum handles the fetch event — browsers use the combination as the installability signal, though exact criteria vary slightly by browser.
Open question page →Advanced · Conceptual · 1 min · Question 10How does background sync differ from just retrying a failed fetch in application code?Open model answer
Model answer
A background sync request registered with the service worker is deferred by the browser until connectivity is actually available, and can complete even if the page that registered it has since been closed, whereas a manual retry in page code only runs while that page stays open and the network hasn't dropped mid-retry.
Open question page →SCScenario questions
Scenario 1
After shipping a bug fix, some users report the site is still broken for them even after a hard refresh, while others see the fix immediately.
- Check whether the cache name or version was bumped for this deploy.
- Check the fetch handler's strategy for the app shell — cache-first would keep serving old files.
- Check whether activate deletes old cache versions.
- Decide and implement a deliberate update strategy instead of leaving it to chance.
Reveal worked answer
This points to the service worker serving a stale cached app shell to some users. I'd first check whether the cache name changed with this deploy — if it didn't, cache-first requests keep matching old cached files regardless of what's actually deployed. I'd also check that activate deletes previous cache versions, and that the update path is deliberate: either detect a waiting worker and prompt the user to refresh, or call skipWaiting()/clients.claim() paired with a full reload, so a user never runs old HTML against new assets or vice versa.