JavaScriptService Workers

Service Workers

A service worker is a script the browser runs in the background, outside any page, that can intercept network requests for a given origin. It survives page reloads, can keep working when the tab is closed, and is the foundation of Progressive Web Apps — offline mode, push notifications, background sync. The model is different from anything else in the browser, so take the lifecycle slowly.

What a service worker can do
  • Act as a network proxy for its registered scope — every fetch, every <img>, every navigation can be intercepted.

  • Cache responses with the Cache API and serve them when the network is offline.

  • Receive push notifications while the user is away.

  • Trigger background sync when connectivity returns.

  • Run in a separate thread, with no DOM access (like a worker).

HTTPS only
Service workers only run on `https:` origins (and `localhost`). They have so much power — they can rewrite any response your page asks for — that the browser refuses to register one on an insecure origin.
Registering one

register from your normal page

JS
if ("serviceWorker" in navigator) {
  window.addEventListener("load", async () => {
    try {
      const reg = await navigator.serviceWorker.register("/sw.js", {
        scope: "/",
      });
      console.log("registered scope:", reg.scope);
    } catch (err) {
      console.warn("registration failed:", err);
    }
  });
}

The scope decides which URLs the worker controls. scope: "/" covers the whole origin. A service worker at /app/sw.js defaults to controlling only /app/. Browsers refuse a scope above the worker's own path unless the response has a Service-Worker-Allowed header.

The lifecycle

The state machine is the trickiest part. A new service worker goes through:

  • install — fires once, the first time this version of the script is fetched. Typical use: open caches and pre-fetch core assets.

  • activate — fires once the new worker is ready to take over. Typical use: clean up old caches from previous versions.

  • fetch — fires for every network request whose URL is in scope. This is where you decide cache vs network.

sw.js

JS
const VERSION = "v3";
const ASSETS = ["/", "/styles.css", "/app.js", "/offline.html"];

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(VERSION).then((cache) => cache.addAll(ASSETS))
  );
  self.skipWaiting(); // become the active worker immediately
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const keys = await caches.keys();
      await Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)));
      await self.clients.claim();
    })()
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(handle(event.request));
});

A new worker normally waits until every tab from the old version has closed before activating. skipWaiting() + clients.claim() are the override.

Caching strategies

Four strategies cover most real apps. Pick the one that matches the asset's freshness needs.

  • Cache first — check cache, fall back to network. Best for fingerprinted static assets (/app.abc123.js).

  • Network first — try network, fall back to cache on failure. Best for HTML and APIs that should be fresh when online.

  • Stale-while-revalidate — return the cached copy immediately, fetch a fresh one in the background and update the cache. Best for frequently-changing but non-critical content.

  • Network only / Cache only — never mix. Useful for POSTs and dev assets.

stale-while-revalidate

JS
async function handle(request) {
  const cache = await caches.open(VERSION);
  const cached = await cache.match(request);

  const network = fetch(request).then((res) => {
    cache.put(request, res.clone());
    return res;
  });

  return cached || network;
}
Offline fallback

JS
async function handle(request) {
  try {
    return await fetch(request);
  } catch {
    if (request.mode === "navigate") {
      const cache = await caches.open(VERSION);
      return cache.match("/offline.html");
    }
    return new Response("", { status: 504 });
  }
}
Updating a service worker

Every time the browser fetches the SW script for any reason, it byte-compares the result with the installed copy. One byte different counts as a new version and triggers the install/activate cycle. Always serve the SW file with a short HTTP cache TTL (or Cache-Control: max-age=0) — otherwise users get stuck on old versions.

Push notifications

The SW listens for push events from a server (via the Push API + your VAPID key) and shows a notification using self.registration.showNotification. We unpack the user-facing side on the Notifications API page.

JS
self.addEventListener("push", (event) => {
  const data = event.data?.json() ?? { title: "Hello" };
  event.waitUntil(self.registration.showNotification(data.title, { body: data.body }));
});
Devtools is your friend
Chrome's Application panel → Service Workers gives you "Update on reload", "Skip waiting", and "Unregister" buttons. Use them while developing — without them, debugging an SW update is a nightmare.