How Modern Web Apps Work Offline Using Service Workers
A service worker is a programmable proxy sitting between your page and the network. Here's the lifecycle, the caching strategies worth knowing, and the update bug almost everyone ships at least once.
For most of the web's history, "offline" meant a dinosaur game. A page either reached the server or it didn't. Service workers changed that by giving JavaScript a place to stand between the page and the network — and once you control that layer, an offline app stops being a special case and becomes the default.
This is how they actually work, including the part that bites everyone once.
A service worker is a proxy, not a background script
The mental model that helps most: a service worker is a programmable proxy
running in its own thread, with no DOM access and no shared memory with your
page. It has no window, no document, and it can be killed and restarted by
the browser at any moment between events. Any state you want to survive has to
live in a cache or IndexedDB, never in a module-level variable.
It also only runs over HTTPS. localhost is exempt so you can develop, but the
moment you deploy, a service worker over plain HTTP is silently ignored.
Registration happens from the page:
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js");
});
}The location of the file determines its scope. A worker served from
/sw.js can intercept every request on the origin. One served from
/app/sw.js can only see requests under /app/. This catches people out when
a bundler emits the worker into an assets directory and it quietly stops
controlling the site root.
The lifecycle, and why your update didn't apply
Three states matter:
- Install — fires once per version of the worker. This is where you populate the cache.
- Waiting — a new worker that has installed but can't take over yet, because the old one is still controlling open tabs.
- Activate — the new worker is in charge. This is where you delete old caches.
That waiting state is the thing that surprises people. A new service worker does not take over just because the user refreshed. A refresh keeps the page controlled by the old worker — the client was never actually released. The new worker takes over only when every tab under its scope has been closed, not reloaded.
So a user sees stale content, hits refresh, still sees stale content, and concludes your deploy is broken. It isn't; it's doing exactly what the spec says.
You have two honest options. Either call skipWaiting() and accept that assets
may swap under a running page, or leave the default and tell the user an update
is ready:
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open("assets-v3").then((cache) =>
cache.addAll(["/", "/index.html", "/app.css", "/app.js"]),
),
);
});
self.addEventListener("activate", (event) => {
// Drop every cache that isn't the current version, or they accumulate
// until the browser evicts the whole origin.
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== "assets-v3").map((k) => caches.delete(k)),
),
),
);
});Precaching versus runtime caching
These solve different problems and conflating them causes most cache bugs.
Precaching happens at install time, from a manifest generated at build time. You know the exact list of files and their content hashes, so you can fetch all of them up front and be certain the app shell is complete. This is what makes a genuinely offline-capable app: after the first visit, every asset needed to boot is already local.
Runtime caching happens on demand, in the fetch handler, for things you
can't know in advance — API responses, user avatars, third-party images.
The strategies worth memorising:
| Strategy | Behaviour | Use for |
|---|---|---|
| Cache first | Serve cache, never touch network on a hit | Hashed, immutable build assets |
| Network first | Try network, fall back to cache | API data where freshness matters |
| Stale-while-revalidate | Serve cache instantly, update in background | Content that can lag one visit |
| Network only | Bypass cache | Analytics, mutations |
A cache-first handler is about ten lines:
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
event.respondWith(
caches.match(event.request).then((hit) => hit ?? fetch(event.request)),
);
});Note the GET guard. Only GET requests belong in the Cache API — a cached
POST is a bug waiting to happen.
Client-side routing needs a navigation fallback
If your app renders routes in JavaScript, a hard refresh at /settings asks
the server for a file that doesn't exist. Offline, it asks the cache for a file
that isn't there either. The fix is to answer every navigation request with
the cached shell and let the router sort it out:
self.addEventListener("fetch", (event) => {
if (event.request.mode === "navigate") {
event.respondWith(
caches.match("/index.html").then((hit) => hit ?? fetch(event.request)),
);
}
});Don't hand-write this in production
Everything above is worth understanding, and almost none of it is worth maintaining by hand. The failure modes — a stale precache manifest, a cache that never gets cleaned up, a scope that silently narrowed — are tedious and easy to miss.
Workbox generates the precache
manifest from your actual build output, with content hashes, so the worker
updates precisely when a file changes and not otherwise. In OmniPlay we use it
through vite-plugin-pwa, which wires the generated worker into the build and
handles registration. The configuration is a few lines; the behaviour is the
same as above, correct.
How to actually verify it
Do not trust "it seems to work." Open DevTools → Application → Service Workers, tick Offline, and hard-reload. If the app boots, renders and stays usable, it's genuinely offline-capable. If it white-screens, something in the boot path is still hitting the network — most often a font, an analytics script, or an API call the shell can't render without.
That last category is the real design constraint, and it's worth stating plainly: an app is only as offline-capable as its least optional network request.