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.
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.jscontrols the whole origin; one served from/app/sw.jscontrols 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.
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.
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.
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.
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-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.
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));
}
});
Cleaning up old caches on activate
Every time you ship a new worker, you typically bump the cache name (shell-v4 → shell-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.display—standalonedrops the browser’s address bar and tabs so it feels native;fullscreen,minimal-ui, andbrowserare the other options.icons— sizes for various surfaces. Include a512x512and amaskableicon 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.
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:
- The page asks permission and subscribes via
PushManager, getting a subscription object (an endpoint URL plus encryption keys). - You send that subscription to your server and store it.
- Later, your server pushes an encrypted message (using the VAPID protocol) to the endpoint.
- The browser wakes your worker with a
pushevent; 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 ?? "/"));
});
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 underabout: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()andclients.claim()let it take over immediately — use them knowing they can mix old and new code mid-session. - The
fetchevent withevent.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_coloris 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.