How Modern Web Apps Work Offline Using Service Workers

4 min readservice-workers · pwa · offline

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:

  1. Install — fires once per version of the worker. This is where you populate the cache.
  2. Waiting — a new worker that has installed but can't take over yet, because the old one is still controlling open tabs.
  3. 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.