Service Workers & PWAs

Open a normal web page with the network off and you get the dinosaur, or a blank frame, or a spinner that never resolves. The page has no memory of itself. Every asset it needs lives on a server it can no longer reach.

A service worker breaks that dependency. It is a small script the browser keeps running in the background, separate from any page, that sits between your app and the network and gets to answer requests itself. Load the app once, and the service worker can stash the HTML, CSS, JS, and data locally. Load it again on a plane, and the same worker serves everything from that stash. The app opens. It works.

That single capability — a scriptable proxy you control — is what turns a website into a Progressive Web App: something installable to the home screen, launchable in its own window, and usable offline. This article is about how that machinery actually works.

without a service workerpagerequestnetworkwith a service workerpageserviceworkermaybenetworkcache
Without a service worker the page depends entirely on the network. With one, an interceptor sits in between and can answer from a local cache.

Service workers are a W3C standard and are Baseline widely available — supported across Chrome, Edge, Firefox, and Safari on desktop and mobile for years. So is the Cache API they lean on. A few pieces built on top (Background Sync, some Push details) are less evenly supported, and I’ll flag those honestly when we get there.

The mental model: a proxy you write

A service worker is JavaScript, but it does not run on a page. It runs in its own worker thread with no DOM, no window, no direct access to your page’s variables. Like a web worker, it talks to pages by messages. What makes it special is what it can intercept: once it controls a page, every network request that page makes — every image, every fetch, every navigation — passes through the worker’s fetch event first.

So the worker is a request router. For each request it decides: answer from the cache, go to the network, do both, synthesize a response from scratch, or fall through and let the browser handle it normally. You write that decision.

Two constraints fall directly out of this power:

  • HTTPS only. A script that can rewrite every response on your origin is a perfect man-in-the-middle attack if an attacker can inject it. So the browser refuses to register a service worker except over HTTPS. The one exception is http://localhost, so you can develop without certificates.
  • Scope. A worker only controls pages under its own path. A worker served from /sw.js controls the whole origin; one served from /app/sw.js controls only /app/ and below. The URL where the file lives sets the default scope.

Move the worker file around and watch which requests it would control. The default scope is simply the directory the script lives in, and a page is controlled only if its path starts with that scope.

interactiveWhich pages does this worker control?

Registering a worker

Registration happens from a normal page. You ask the browser to install a script; it does the work in the background and resolves a promise with a registration object.

// in your page's main script
if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("/sw.js", { scope: "/" })
    .then((reg) => {
      console.log("registered, scope:", reg.scope);
    })
    .catch((err) => {
      console.error("registration failed:", err);
    });
}

The feature check matters less than it used to, but it costs nothing and keeps old browsers from throwing. Registration is cheap and idempotent — calling it on every page load is normal and correct. The browser compares the new file byte-for-byte against the installed one and only does anything if it changed.

Notice what registration does not do: it does not make the current page controlled. The very first time a visitor arrives, the worker installs and activates, but the page that registered it is already loaded and running uncontrolled. Only the next navigation runs through the worker — unless you deliberately claim control, which we’ll get to.

The lifecycle

The lifecycle is the part people trip over, so it’s worth drawing. A new worker moves through a small state machine: it installs, then waits, then activates, then controls pages. The waiting step is the surprising one.

installinginstall eventwaitinginstalledactivatingactivate eventactivatedcontrols clientsokold clients goneskipWaiting()clients.claim() → controls open pages too
A worker installs, waits until old clients are gone, activates, then controls pages. skipWaiting and clients.claim short-circuit the two slow transitions.

install

The worker’s global script runs, and an install event fires. This is where you do one-time setup — most commonly, precache the files your app shell needs. You keep the worker alive during that async work with event.waitUntil(promise): the browser considers the worker “installing” until the promise settles, and treats a rejection as a failed install.

const CACHE = "shell-v3";
const SHELL = ["/", "/styles.css", "/app.js", "/offline.html"];

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

If any file in addAll fails to fetch, the whole install rejects and this worker version never activates. That’s a feature: a half-cached shell would be worse than none.

waiting

Here’s the transition that confuses people. Once installed, a new worker does not take over. If an older version is still controlling open tabs, the new one sits in a waiting state, ready but idle. It activates only when every page controlled by the old worker has been closed — not reloaded, closed. Reloading a tab keeps the old worker in charge because the page never fully goes away.

This is deliberate. It guarantees that all your open tabs run the same worker version and can’t get a mismatched mix of cached assets. But it means users can run stale code for a long time. Two escape hatches exist.

skipWaiting and clients.claim

self.skipWaiting() tells a waiting worker to jump straight to activating without waiting for old clients to close. Call it in install:

self.addEventListener("install", (event) => {
  self.skipWaiting();
  event.waitUntil(precache());
});

That fixes the activation delay. But activating doesn’t retroactively grab pages that were loaded before the worker existed. For that, in the activate handler, call clients.claim():

self.addEventListener("activate", (event) => {
  event.waitUntil(self.clients.claim());
});

clients.claim() makes the active worker the controller of every in-scope page immediately, firing a controllerchange event on those pages. Together the two turn “controls the next navigation, eventually” into “controls everything, now.”

The waiting rule is easiest to feel by driving it. Deploy a new version with tabs open and watch it sit in waiting; close the last tab and it activates. Then tick skipWaiting() and deploy again to see it jump the queue.

interactiveThe waiting state, hands-on

The fetch event: where caching happens

Once a worker controls a page, its fetch handler runs for every request that page makes. You inspect the request and, if you want to handle it, call event.respondWith(response) with a Response (or a promise for one). Don’t call respondWith, and the request proceeds to the network as if the worker weren’t there.

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request);
    })
  );
});

That five-line handler is a complete offline strategy: look in the cache, and if the request isn’t there, go to the network. It’s the “cache-first” strategy, which we’ll formalize in a moment.

A request flowing through the fetch handler
1/4
Variables
url="/app.js"
The page requests /app.js. Because the worker controls the page, the fetch event fires here instead of hitting the network directly.

The Cache API

The cache lives in caches, a global CacheStorage. It is not localStorage for strings and not IndexedDB for objects — it stores Request/Response pairs, the actual HTTP exchanges. It’s asynchronous, promise-based, and available in both pages and workers.

The pieces you’ll use constantly:

const cache = await caches.open("shell-v3"); // open (or create) a named cache

await cache.addAll(["/", "/app.js"]);        // fetch + store several URLs
await cache.add("/logo.svg");                // fetch + store one URL
await cache.put(request, response);          // store a response you already have

const hit = await cache.match("/app.js");    // Response, or undefined
const any = await caches.match("/app.js");   // search across ALL caches

await cache.delete("/old.js");               // remove one entry
await caches.delete("shell-v2");             // drop an entire named cache
const names = await caches.keys();           // list cache names

One sharp edge: a Response body is a stream you can read once. If you want to both cache a response and return it to the page, clone it first — cache.put(req, res.clone()) — and hand the original to respondWith. Read it twice without cloning and the second read throws.

Three caching strategies

There is no single right way to cache. The right choice depends on how much you value freshness versus speed for a given resource. Three patterns cover almost everything.

cache-firstcache?hitreturn itmissnetworknetwork-firstnetwork?okreturn + cachefor next timefailcachestale-while-revalidateserve cachenow (fast)meanwhilefetch freshupdate cachenext visit getsthe fresh copy
Three strategies as decision flows. Cache-first favors speed, network-first favors freshness, stale-while-revalidate serves fast and refreshes in the background.

Cache-first

Check the cache; only hit the network on a miss. Fastest possible response, works offline the moment an asset is cached. Ideal for immutable, versioned static assets — hashed bundles like app.4f3a.js, fonts, icons. Because the filename changes when the content changes, staleness is a non-issue.

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const fresh = await fetch(request);
  const cache = await caches.open("assets-v1");
  cache.put(request, fresh.clone());
  return fresh;
}

Network-first

Try the network; fall back to the cache when it fails or times out. Freshest data when online, still functional offline. Use it for things that must be current — API responses, an activity feed, an HTML document whose content changes.

async function networkFirst(request) {
  try {
    const fresh = await fetch(request);
    const cache = await caches.open("pages-v1");
    cache.put(request, fresh.clone());
    return fresh;
  } catch {
    const cached = await caches.match(request);
    if (cached) return cached;
    return caches.match("/offline.html"); // last resort
  }
}

Stale-while-revalidate

Serve the cached copy immediately and fetch a fresh one in the background to update the cache for next time. The user gets an instant response now and up-to-date content on the following visit. Great for content that changes but where a one-visit lag is fine — avatars, non-critical widgets, article lists.

async function staleWhileRevalidate(request) {
  const cache = await caches.open("swr-v1");
  const cached = await cache.match(request);
  const network = fetch(request).then((fresh) => {
    cache.put(request, fresh.clone());
    return fresh;
  });
  return cached || network; // cache now, network if nothing cached yet
}

The differences are hard to appreciate in prose but obvious once you can toggle the network and watch each strategy respond. Below is a tiny simulated cache and server. Flip the network offline, publish new content on the server, and hit each strategy to see what it returns and how it leaves the cache.

interactiveCache-first vs network-first vs stale-while-revalidate

Precaching the app shell

The “app shell” is the minimal HTML, CSS, and JavaScript that renders your app’s frame — navigation, layout, the loading state — before any dynamic data arrives. Precache it during install and your app opens instantly and offline, then fills in data over the network.

const SHELL_CACHE = "shell-v4";
const SHELL_FILES = [
  "/",
  "/index.html",
  "/app.css",
  "/app.js",
  "/logo.svg",
  "/offline.html",
];

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

Now route in fetch: shell assets go cache-first, navigations go network-first with the shell as fallback, API calls go network-first or stale-while-revalidate.

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.mode === "navigate") {
    event.respondWith(networkFirst(request));
  } else if (request.url.includes("/api/")) {
    event.respondWith(networkFirst(request));
  } else {
    event.respondWith(cacheFirst(request));
  }
});
pagerenders ✓service workerfetch handlernetworkofflineprecached shell ✓
Network down. The page still requests assets; the worker serves them from the precached shell, so the app renders instead of failing.

Cleaning up old caches on activate

Every time you ship a new worker, you typically bump the cache name (shell-v4shell-v5). The old cache doesn’t vanish on its own — it just sits there taking up quota. The activate event is the safe moment to delete stale caches, because by then no page is still being served by the previous worker.

const CURRENT = new Set(["shell-v5", "api-v2"]);

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((name) => !CURRENT.has(name))
          .map((name) => caches.delete(name))
      )
    )
  );
});

The Web App Manifest

The service worker makes the app work offline. The manifest makes it installable — describing how it should look and behave when a user adds it to their home screen or launches it as a standalone window. It’s a JSON file linked from your HTML.

<link rel="manifest" href="/manifest.webmanifest" />
{
  "name": "Tide Tracker",
  "short_name": "Tides",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#0b1020",
  "theme_color": "#3b82f6",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
    {
      "src": "/icons/maskable.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

The members that matter most:

  • name / short_name — the full name and the short label under the home-screen icon.
  • start_url — where the app opens when launched. Add a query param and you can tell PWA launches apart from browser visits in analytics.
  • displaystandalone drops the browser’s address bar and tabs so it feels native; fullscreen, minimal-ui, and browser are the other options.
  • icons — sizes for various surfaces. Include a 512x512 and a maskable icon so Android can crop it to any shape without clipping your logo.
  • theme_color / background_color — the toolbar/status-bar tint and the splash background shown while the app loads.
manifest.webmanifestnameicons[]displaytheme_colorstart_urltheme_colorTidesstandaloneicon
The manifest maps JSON members onto what the user sees: the installed icon, the standalone window, and the theming.

Beyond the basics: Background Sync and Push

Two capabilities extend a service worker past “runs while a tab is open” into “does work even when no page is.” Both are real and standardized; both have support caveats worth stating plainly.

Background Sync

Background Sync lets you defer an action until the device has connectivity. The user hits “send” while offline; instead of failing, you register a sync and let the browser fire a sync event in your worker once the network returns — even if the user has since closed the tab.

// in the page
const reg = await navigator.serviceWorker.ready;
await reg.sync.register("send-messages");
// in the service worker
self.addEventListener("sync", (event) => {
  if (event.tag === "send-messages") {
    event.waitUntil(flushOutbox()); // read queued msgs from IndexedDB, POST them
  }
});

The pattern is: queue the work durably (usually in IndexedDB), register a sync tag, and drain the queue in the sync handler. The browser handles retries and picks the moment.

Push notifications

Push lets a server wake your service worker and show a notification even when the app is fully closed. It’s a two-standard dance: the Push API delivers the message to the worker, and the Notifications API displays it.

The flow:

  1. The page asks permission and subscribes via PushManager, getting a subscription object (an endpoint URL plus encryption keys).
  2. You send that subscription to your server and store it.
  3. Later, your server pushes an encrypted message (using the VAPID protocol) to the endpoint.
  4. The browser wakes your worker with a push event; you display a notification.
// in the page: subscribe
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: VAPID_PUBLIC_KEY, // a Uint8Array
});
await fetch("/save-subscription", {
  method: "POST",
  body: JSON.stringify(sub),
});
// in the service worker: receive and display
self.addEventListener("push", (event) => {
  const data = event.data?.json() ?? {};
  event.waitUntil(
    self.registration.showNotification(data.title ?? "New", {
      body: data.body,
      icon: "/icons/192.png",
    })
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data?.url ?? "/"));
});
pagesubscribeyour serverstores subpush servicebrowser vendorworkershows notifsaveVAPID pushwake
Push: the app subscribes, your server stores the subscription and later pushes through the browser's push service, which wakes the worker to show a notification.

Debugging service workers

The lifecycle makes for confusing bugs — you edit sw.js, reload, and see nothing change because the old worker is still in charge. A few habits save hours:

  • Application → Service Workers in Chrome DevTools shows the current worker, its state, and buttons to skipWaiting, unregister, and update. Firefox has the same under about:debugging.
  • “Update on reload” in that panel forces a fresh worker on every load during development, sidestepping the waiting dance.
  • Application → Cache Storage lets you inspect exactly what’s cached and delete entries by hand.
  • “Offline” in the Network panel simulates a dead connection so you can prove your cache-first paths actually work.
  • When state gets truly weird, unregister the worker and clear storage, then hard-reload. A stale worker plus a stale cache produces symptoms that look impossible until you wipe both.

Summary

  • A service worker is a background script that acts as a programmable proxy between your pages and the network. It has no DOM, runs over HTTPS only (plus localhost), and controls only pages within its scope, set by the script’s location.
  • The lifecycle is register → install → (wait) → activate → control. A new worker waits until old clients close; self.skipWaiting() and clients.claim() let it take over immediately — use them knowing they can mix old and new code mid-session.
  • The fetch event with event.respondWith() is where you route requests. The Cache API (caches.open, cache.addAll, cache.match, cache.put) stores real Request/Response pairs; clone a response before caching and returning it.
  • Three strategies cover most needs: cache-first (versioned assets), network-first (live data with offline fallback), stale-while-revalidate (instant now, fresh next time). Precache the app shell on install and delete old versioned caches on activate.
  • The Web App Manifest makes an app installable — name, icons, display: standalone, start_url, colors. Installability needs a manifest, icons, HTTPS, and a service worker with a fetch handler. theme_color is not Baseline.
  • Background Sync defers actions until connectivity returns (Chromium only — not Baseline). Push wakes the worker to show notifications even when closed (broad support, but iOS requires an installed PWA). Both are progressive enhancements: feature-detect and degrade gracefully.