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).
Registering one
register from your normal page
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
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
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
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.
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? { title: "Hello" };
event.waitUntil(self.registration.showNotification(data.title, { body: data.body }));
});