Overview
I added a service worker to a small web app last month, mostly to see if the "install to home screen" prompt would show up. It did, and the app now opens in its own window, works offline for cached pages, and loads instantly on repeat visits. The whole thing was about 80 lines of code.
The catch is that service workers are stateful, run in a separate lifecycle, and have failure modes that only show up in production. This is what I learned getting one working.
What a service worker actually is
A service worker is a JavaScript file that runs in a background thread, separate from the page. It acts as a proxy between your app and the network, and it can:
- Intercept every fetch request the page makes
- Serve responses from a cache instead of the network
- Sync data in the background
- Receive push notifications
- Persist across page reloads and browser restarts
It has no DOM access and no window. It can't touch the page directly; it communicates via postMessage.
The lifecycle
| State | What's happening |
|---|---|
| Parsed | File downloaded, syntax checked |
| Installing | install event fires; cache your assets |
| Installed | Waiting for all old tabs to close |
| Activating | activate event fires; clean up old caches |
| Activated | Now intercepting requests |
| Redundant | Replaced by a newer version |
The state that trips people up is installed but waiting. When you deploy an update, the new service worker downloads and installs, but doesn't activate until every tab running the old version is closed. This is intentional — it prevents breaking the page in a user's open tab — but it means your updates don't take effect immediately.
Skip the wait with skipWaiting():
self.addEventListener("install", (event) => {
self.skipWaiting();
event.waitUntil(
caches.open("v1").then((cache) => cache.addAll(["/", "/app.js", "/styles.css"]))
);
});
self.addEventListener("activate", (event) =>
event.waitUntil(self.clients.claim())
);
skipWaiting() activates the new worker immediately. clients.claim() takes control of existing pages. Together they mean updates apply on the next page load. The tradeoff is that the page currently open might be running code that expects the old version — so don't do this if you're making breaking changes.
A minimal working service worker
// sw.js
const CACHE = "app-v1";
const ASSETS = [
"/",
"/index.html",
"/app.js",
"/styles.css",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(ASSETS))
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
)
)
);
});
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
Register it from the page:
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").then(
(reg) => console.log("SW registered:", reg.scope),
(err) => console.error("SW registration failed:", err)
);
});
}
Three things about this code:
- The service worker must be served from the root (or a path that scopes the pages you want to control). A service worker at
/js/sw.jscan only intercept requests under/js/. Put it at/sw.jsunless you have a specific reason not to. - HTTPS is required except on localhost. Chrome, Firefox, and Safari all reject service worker registration on plain HTTP.
- The
installevent fails if any asset fails to cache. One 404 in your ASSETS list and the whole install fails silently. Check the console during development.
Caching strategies
The simple cache-first strategy above works for static assets. For a real app, you want different strategies per route type.
Cache-first
async function cacheFirst(request) {
const cached = await caches.match(request);
return cached || fetch(request);
}
Good for: static assets (CSS, JS, images with hashed names). Fast, but updates require a cache version bump.
Network-first
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE);
cache.put(request, response.clone());
return response;
} catch {
return caches.match(request);
}
}
Good for: HTML pages, API responses that should be fresh. Always fetches, falls back to cache when offline.
Stale-while-revalidate
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
Good for: avatars, icons, anything where slightly stale is acceptable. Returns the cached version immediately and updates the cache in the background.
Picking strategies per route
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== "GET") return;
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) {
event.respondWith(networkFirst(request));
} else if (url.pathname.startsWith("/static/")) {
event.respondWith(cacheFirst(request));
} else {
event.respondWith(staleWhileRevalidate(request));
}
});
The early returns matter. Don't cache POST requests, don't cache cross-origin requests, and don't intercept API calls unless you want to handle offline state for them.
The cache versioning problem
Your service worker caches a JavaScript file. You deploy a new version. The service worker serves the old one from cache forever, and your users are running stale code with no way to know.
The fix is a versioned cache name. Change CACHE = "app-v1" to "app-v2" on every deploy. The activate handler deletes the old cache, and the new one starts empty.
The automation I use — build-time replacement:
// sw.js (template)
const CACHE = "app-{{BUILD_HASH}}";
// build script
const fs = require("fs");
const hash = require("crypto")
.createHash("md5")
.update(Date.now().toString())
.digest("hex")
.slice(0, 8);
const sw = fs
.readFileSync("sw.js", "utf8")
.replace("{{BUILD_HASH}}", hash);
fs.writeFileSync("dist/sw.js", sw);
Every deploy gets a new cache name, old caches are cleaned up, and there's no way to serve stale content indefinitely.
Making it a PWA
A service worker makes an app installable but not necessarily pleasant. To get the full PWA treatment, you need a manifest.
{
"name": "My Notes App",
"short_name": "Notes",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icon-192.png">
| Field | Effect |
|---|---|
display: standalone | Opens without browser UI |
theme_color | Colors the status bar on Android |
maskable icon | Safe zone for adaptive icons on Android |
apple-touch-icon | iOS doesn't fully support the manifest; this link is what iOS uses |
Chrome shows the install prompt when the app meets criteria: served over HTTPS, has a manifest with name, icons, start_url, and display, and has a registered service worker with a fetch handler. Safari doesn't show a prompt — users install manually via Share → Add to Home Screen.
Testing and debugging
Chrome DevTools has an Application tab with everything you need:
- Service Workers — current status, update, unregister, bypass for network
- Cache Storage — every cached file, browseable and deletable
- Manifest — the parsed manifest with installability warnings
- Storage — total usage, with a clear-all button
The "Update on reload" checkbox in the Service Workers panel is what keeps you sane. With it checked, every reload fetches a fresh service worker and re-registers it. Without it, you're testing against the old one and wondering why your changes didn't take effect.
Unregistering is harder than you'd think. If a user's browser has a service worker that's broken, they can't just refresh to fix it. The service worker keeps intercepting requests. You need a mechanism to force an update — typically a query parameter in the registration, or a small script that calls registration.unregister() from the page.
The nuclear option when a service worker goes wrong in production:
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.map((k) => caches.delete(k)))
).then(() => self.registration.unregister())
);
});
This deletes every cache and unregisters itself. Push it, wait for all your users to load a page once, then push a fixed version.
What I'd tell someone starting
- Start with cache-first for static assets only. Don't cache HTML or API responses until you understand the implications.
- Version the cache name on every deploy. Automate it. Manual version bumps get forgotten.
- Test offline mode in DevTools with the network tab set to offline. Not by disconnecting your Wi-Fi.
- Add a way to unregister. If nothing else, the nuclear option above, in a page that says "we're updating, please reload."
- Don't cache large media files. The cache has a quota — usually around 50% of disk usage per origin — and filling it hurts every other site in the browser.
For a small app, a service worker is 80 lines and gives you offline support, instant loads, and installability. The lifecycle is the only part that requires care, and once you understand the waiting state, everything else follows.
