Quick Answer

A service worker is a script running separately from your page that can intercept network requests and serve responses from a cache. It enables offline support, and its update lifecycle means a buggy one can persist long after you deploy a fix.

What it is

A JavaScript file that runs in its own worker, separate from any page. It has no DOM access, cannot touch window, and continues to exist after your tab closes.

Its defining ability is intercepting fetch events — every request the page makes passes through it, and it decides whether to hit the network, return something cached, or synthesise a response entirely.

That makes it a programmable proxy running on the user's device. It is why offline web apps are possible, and why the browser requires HTTPS: allowing an attacker to install one would let them rewrite every response indefinitely.

The lifecycle, which is where the traps are

Three events, and understanding them prevents most service worker pain.

// sw.js
const CACHE = "app-v1";

self.addEventListener("install", (e) => {
  e.waitUntil(
    caches.open(CACHE).then((c) => c.addAll(["/", "/style.css", "/app.js"]))
  );
});

self.addEventListener("activate", (e) => {
  e.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
    )
  );
});

install runs once for a new service worker — precache your shell here. activate runs when it takes control — delete old caches here, or they accumulate forever.

The trap: a new service worker does not take control immediately. It waits until every tab using the old one has closed. Reloading is not enough, because the reload keeps the tab alive. This is why you deploy a fix and still see old behaviour.

self.skipWaiting() in install plus clients.claim() in activate makes the new worker take over at once. Use them deliberately — taking control mid-session can leave a page running old code alongside new assets.

Intercepting requests

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

Cache first: if it is cached, return it without touching the network; otherwise fetch normally. Fast and offline-capable — and it will serve a stale file forever if you cache something that changes.

The strategies worth knowing:

  • Cache first — for content-hashed assets whose filename changes when the content does. Safe to cache indefinitely.
  • Network first — for HTML and API responses, falling back to cache when offline. Always current when online.
  • Stale while revalidate — serve the cached copy immediately and update the cache in the background. Fast, with content one visit behind.
  • Network only — for anything that must never be cached, such as authenticated API calls or analytics.

Match the strategy to the resource. A single blanket strategy across everything is how sites end up serving a month-old homepage.

The stale content problem

This is the failure mode that gives service workers their reputation, and it is worth stating plainly.

You cache /app.js with a cache-first strategy. You deploy a fix. Users keep the old file, because the service worker never asks the network. Your fix reaches nobody, and you cannot tell from the server side.

Three defences:

  • Cache content-hashed filenames only. app.a3f9c1.js can be cached forever because a change produces a different name. Never aggressively cache a non-hashed filename.
  • Version the cache name and delete old versions on activate, as above. Bumping the version invalidates everything from the previous release.
  • Never cache-first your HTML. The HTML references the hashed assets, so a stale HTML file points at old assets and undoes the entire scheme.

Keep a kill switch: a service worker that unregisters itself and clears caches, ready to deploy if something goes badly wrong. Without one, fixing a broken service worker in the field is genuinely difficult.

Practical notes

  • Use a library. Workbox handles precaching, routing, strategies and update flows. Hand-writing a correct service worker is possible and rarely the best use of your time.
  • Scope is by directory. A worker at /js/sw.js controls only /js/. Serve it from the root so it controls the whole site — a very common confusion.
  • Debug in the Application panel. It shows the current worker, any waiting one, and offers Update-on-reload and Bypass-for-network, which make development bearable.
  • Test offline properly using the offline toggle in developer tools, not by turning off wifi with a warm HTTP cache.
  • Do not cache authenticated responses in a shared cache — a cached personalised page can be served to a different user of the same device.

See PWA basics for how this fits with the manifest, and caching strategies for the same trade-offs on the server.

Frequently Asked Questions

Why does my updated service worker not take effect? A new worker waits until all tabs using the old one are closed. Reloading keeps the tab alive, so it stays waiting. Use skipWaiting and clients.claim, or close every tab.
Why is my site serving old files after deploying? A cache-first strategy on a non-hashed filename never checks the network again. Cache content-hashed filenames aggressively and use network-first for HTML.
Do service workers require HTTPS? Yes, except on localhost for development. They can intercept every request, so allowing them over plain HTTP would let an attacker install a permanent interceptor.
What is the scope of a service worker? The directory it is served from and everything beneath it. Serve it from the site root if it should control the whole site.
Should I write a service worker by hand? For learning, yes. For production, a library such as Workbox handles precaching, routing and update flows correctly, which removes the most error-prone parts.