Skip to content

Service workers without superstition

A new release fixed a broken page, but installed phones kept showing the old behavior. The server returned the new assets. Fresh browser tabs worked. The installed app did not change until it was closed and opened again.

Nothing mystical had happened. A service worker still controlled the old page, and the page still ran the old JavaScript bundle.

Service workers are often treated as an offline switch. They are better treated as programmable network proxies with a lifecycle. That proxy can start an app without a network. It can also cache login HTML, pin broken files, serve stale documents, or hide the fact that a request never reached the server.

Map the boundary first

text
navigate to app
      |
      v
service worker
  |        |             |
  |        |             +-> bypass live sync and auth
  |        +-> cached versioned assets
  +-> app shell fallback

running app
  |
  +-> IndexedDB replica and pending writes
  +-> network transport when available

The worker owns delivery of static files. IndexedDB owns durable local data. The sync client owns reconciliation. The server owns authentication and shared authorization when online.

Terminology

Scope. The URL range a worker may control.

Precache. Files recorded during worker installation so the shell can open offline.

Runtime cache. Responses cached as requests occur.

Navigation fallback. A cached HTML entry returned for client-side routes when the network is unavailable.

Cache-first. Return a cached response when present, then use the network only on a miss.

Network-first. Try the network, then fall back to a cached response.

Stale-while-revalidate. Return the cached response now and refresh it in the background.

Waiting worker. A newly installed worker that has not yet taken control because an older page still uses the previous worker.

Choose a strategy by resource

There is no good global strategy.

Use a precached navigation fallback for the app shell. Deny authentication, health, and synchronization routes. A cached login page cannot establish a session, and a cached sync response can lie about shared state.

Use cache-first or stale-while-revalidate for immutable versioned assets. A URL such as /design/1.5.0/mini.css should never change bytes. That contract makes caching safe.

Use network-first with a short timeout for mutable same-origin files when users should see edits soon. Without a timeout, a weak connection can leave the app staring at a pending fetch even though a usable cached response exists.

Bypass writes, WebSockets, and REST change feeds. Let their clients report offline state and retry through their own protocols.

js
self.addEventListener("fetch", event => {
  const request = event.request;
  const url = new URL(request.url);

  if (
    request.method !== "GET" ||
    url.pathname.startsWith("/auth/") ||
    url.pathname.startsWith("/sync/") ||
    url.pathname === "/healthz"
  ) {
    return;
  }

  if (request.mode === "navigate") {
    event.respondWith(networkWithTimeout(request, 2500, "/index.html"));
    return;
  }

  if (/\/assets\/.+\.[a-f0-9]{8}\./.test(url.pathname)) {
    event.respondWith(cacheFirst(request));
  }
});

The omitted helpers should be small and tested. The important part is the routing policy.

Version every generated worker

Generated static apps often have no build pipeline of their own. Their worker can still use a content hash:

js
const CACHE = "app-shell:9d1c7f2a";
const SHELL = ["/", "/index.html", "/app.js", "/app.css"];

self.addEventListener("install", event => {
  event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)));
});

self.addEventListener("activate", event => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(key => key.startsWith("app-shell:") && key !== CACHE)
        .map(key => caches.delete(key)))
    )
  );
});

Hash the emitted files, not the current time. Rebuilding identical input should not create a new cache.

Regenerate the worker after every draft edit that changes deployable files. If an editor can modify app.js without updating the worker's asset list or cache name, offline users receive an accidental mixture of revisions.

Understand update timing

Fetching a new sw.js does not replace code already running in an open page. The browser may install the new worker and leave it waiting until old clients close.

skipWaiting() and clientsClaim() shorten the transition, but they do not hot-swap the JavaScript bundle inside a loaded document. A visible "Update available. Reload" action is often more honest than silently claiming control.

Also serve the worker and web manifest with revalidation headers. Hashed application assets can be immutable. The worker URL itself must be checked for updates.

Authentication and offline access

An installed app that opens from cache does not contact the server's authentication gate. Anyone who can unlock the device may read data already stored in the browser.

That is not necessarily a bug. It is a product and threat-model decision. If offline reading is promised, the device becomes part of the security boundary.

Logout should clear all local layers:

  1. Pending writes and device-only drafts.
  2. The synchronized replica.
  3. Relevant Cache Storage entries.
  4. Service worker registration when the product requires a clean shared-device logout.
  5. The server session.

Ordering depends on the app, but skipping a layer leaves data behind. A cookie-only logout is not enough.

Never cache authentication responses. Do not turn an online redirect into an offline success page. When login requires the network, say so.

Rejected approaches

Cache every GET. This captures personalized pages, stale API responses, and storage without an eviction plan.

Hand-roll a large worker because it offers control. A generator is usually safer for a conventional shell. Custom code is justified when the resource policy actually differs.

Use the worker as the write queue. A worker may be terminated at any time. Durable writes belong in IndexedDB, with the worker acting only as an optional wake-up path.

Enable the worker during ordinary local development. Developers then debug cached builds instead of current source. Test workers against production-like builds.

Assume an offline simulation proves offline behavior. Some browser network controls do not affect service-worker traffic. Use a browser context that is truly offline, reload, and inspect network evidence.

Common failures

  • The precache glob omits fonts, WebAssembly, or a module imported at runtime.
  • A CDN asset was never loaded online, so stale-while-revalidate has nothing to return offline.
  • The navigation fallback claims /auth/login.
  • A generated worker caches /sync/v1/... responses.
  • The app makes a top-level await network call before rendering cached data.
  • A new worker activates, but the page still runs the old bundle.
  • Storage eviction removes caches or IndexedDB on a phone under pressure.
  • An old cache survives because cleanup matches the wrong prefix.
  • The worker registers on load, delaying the first controlled visit more than necessary.

Field checklist

A worker gets the program onto the screen. It does not decide how several copies exchange data. Chapter 14, Build a small sync protocol takes that next step. The durable write path is in Chapter 12.

Built from field notes on durable software systems.