โ๏ธ Service Workers Implementation
A service worker is a script the browser runs in the background, apart from any page, that can intercept and answer network requests. It is the engine behind offline PWAs, background sync, and push notifications. In this lesson you'll build one by hand โ from registration through the full lifecycle to production-ready caching strategies.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Register a service worker and explain its install โ activate โ fetch lifecycle
- Use the Cache API to precache an app shell and clean up old caches
- Implement the five core caching strategies and choose the right one per request
- Wire up background sync and push notifications in the service worker
- Handle service-worker updates safely and debug with DevTools
Estimated Time: 45โ55 minutes โข Difficulty: Intermediate
Hands-on: Build a service worker that precaches a shell and serves it offline, then route different URLs through different strategies.
In This Lesson
What Is a Service Worker?
A service worker is a JavaScript file that runs on its own thread, separate from your pages, and sits between the app and the network as a programmable proxy. Every request a controlled page makes can pass through the worker's fetch handler, which decides whether to answer from a cache, hit the network, or synthesize a response.
๐ก A useful analogy: Think of a service worker as a mail-room clerk between your office (the page) and the outside world (the network). The clerk can hand you a copy from the filing cabinet (cache) instantly, go out to fetch fresh mail when needed, and hold onto your outgoing letters until the road reopens. You never talk to the outside directly โ the clerk mediates.
โ ๏ธ Things a service worker cannot do
- It has no DOM access โ it can't touch the page; it talks to pages via
postMessage. - It is event-driven and terminable โ the browser starts it for an event and may kill it right after, so never rely on global state between events.
- It requires a secure origin (HTTPS or
localhost) and cannot use synchronous APIs likelocalStorage.
The Lifecycle
A service worker moves through a well-defined lifecycle. Understanding it prevents the two classic bugs: stale caches that never update, and a new worker that "won't take control."
Registration (from a page)
Registration happens in your page code, not the worker itself. Feature-detect, then register after load so it doesn't compete with critical resources:
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
console.log('SW registered, scope:', reg.scope);
} catch (err) {
console.error('SW registration failed:', err);
}
});
}
๐ Scope
A worker at /sw.js controls the whole origin (/). One at /app/sw.js only controls /app/ and below. Put the file at the root of the scope you want to control.
Install โ precache the shell
The install event fires once per worker version. Use it to open a cache and store the app shell. event.waitUntil() keeps the worker in installing until the promise settles, so a half-filled cache is never considered ready.
const CACHE = 'shell-v1';
const SHELL = ['/', '/index.html', '/styles/main.css', '/app.js', '/offline.html'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(SHELL))
);
self.skipWaiting(); // activate this version immediately (see "Updating" below)
});
Activate โ clean up old caches
Once a new worker activates, delete caches from previous versions so users don't accumulate stale copies. clients.claim() lets the new worker control already-open pages without a reload.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
).then(() => self.clients.claim())
);
});
Fetch โ intercept requests
The fetch event fires for every request from a controlled page. Call event.respondWith() with a Response (or a promise for one). The strategy you choose here is the heart of the worker โ the next section covers the options.
The Cache API & the App Shell
The Cache API (caches) is a promise-based store that maps Request objects to Response objects. It's separate from the HTTP cache and fully under your control.
const cache = await caches.open('shell-v1');
await cache.addAll(['/', '/styles/main.css']); // fetch + store in one call
await cache.put(request, response.clone()); // store a response you already have
const hit = await cache.match(request); // undefined on a miss
await cache.delete(request); // remove one entry
await caches.delete('shell-v0'); // drop an entire cache
โ ๏ธ A Response body is a one-time stream
A Response can only be read once. If you both return it to the page and store it in the cache, you must clone() it first โ otherwise the second reader gets an empty body. This is the single most common service-worker bug.
const response = await fetch(request);
cache.put(request, response.clone()); // store the copy
return response; // return the original
The Five Caching Strategies
There is no single "correct" strategy โ you pick one per kind of resource based on how fresh it must be versus how fast it should load.
| Strategy | Behavior | Best for |
|---|---|---|
| Cache first | Serve cache; only hit network on a miss | Hashed static assets, fonts, the shell |
| Network first | Try network; fall back to cache when offline | Frequently changing data, article bodies |
| Stale-while-revalidate | Serve cache instantly, refresh it in the background | Avatars, feeds, semi-fresh content |
| Cache only | Never touch the network | Assets guaranteed precached at install |
| Network only | Never touch the cache | Analytics, POSTs, checkout, auth |
Cache first
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const cache = await caches.open(CACHE);
cache.put(request, response.clone());
return response;
}
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 (await caches.match(request)) ?? caches.match('/offline.html');
}
}
Stale-while-revalidate
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE);
const cached = await cache.match(request);
const network = fetch(request).then((response) => {
cache.put(request, response.clone()); // refresh for next time
return response;
});
return cached ?? network; // instant if cached, otherwise wait for network
}
๐ก Why not just cache everything cache-first?
Cache-first is invisible until it isn't: users get stale content and no obvious way to refresh. Reserve it for assets whose URL changes when their content does (e.g. app.a1b2c3.js from a bundler). For anything that mutates, prefer network-first or stale-while-revalidate.
Routing Requests to Strategies
A real worker inspects each request and dispatches it to the right strategy. Here's a realistic e-commerce example inside one fetch handler:
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Never cache non-GET or cross-origin API writes.
if (request.method !== 'GET') return; // let the browser handle it
// Static assets โ cache first
if (['/styles/', '/scripts/', '/images/', '/fonts/'].some((p) => url.pathname.startsWith(p))) {
event.respondWith(cacheFirst(request));
return;
}
// Product data that changes โ network first
if (url.pathname.startsWith('/api/products')) {
event.respondWith(networkFirst(request));
return;
}
// Checkout & cart โ always fresh
if (url.pathname.startsWith('/checkout') || url.pathname.startsWith('/cart')) {
event.respondWith(fetch(request));
return;
}
// Everything else โ stale-while-revalidate
event.respondWith(staleWhileRevalidate(request));
});
โ Workbox does this for you
In production, most teams use Workbox (built into the Angular, Vite, and CRA PWA tooling) instead of hand-writing this. Workbox gives you registerRoute() with named strategy classes, cache expiration, and precache manifests. Learning it by hand first, as you're doing here, means Workbox reads as convenience rather than magic.
Background Sync & Push
Background sync โ don't lose the user's action
When a write fails because the user is offline, queue it in IndexedDB and register a sync tag. The browser fires a sync event once connectivity returns โ even if the page has since closed.
// In the page: try to send; on failure, register a sync.
async function sendMessage(message) {
try {
await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
});
} catch {
await saveToOutbox(message); // store in IndexedDB
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('outbox-sync'); // retry later
}
}
// In the service worker: drain the outbox when online.
self.addEventListener('sync', (event) => {
if (event.tag !== 'outbox-sync') return;
event.waitUntil((async () => {
for (const msg of await readOutbox()) {
const res = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg)
});
if (res.ok) await removeFromOutbox(msg.id);
}
})());
});
Push notifications
Push lets your server wake the worker to show a notification while the app is closed. The user must grant permission, and you subscribe with a VAPID public key:
// In the page: subscribe after permission is granted.
async function subscribeToPush() {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
await fetch('/api/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}
// In the service worker: show the notification, handle clicks.
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? 'Update', {
body: data.body,
icon: '/icons/icon-192.png',
data: { url: data.url }
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url ?? '/'));
});
๐ userVisibleOnly
Browsers require userVisibleOnly: true โ every push must result in a visible notification. This prevents "silent push" being abused to track users or run background code invisibly.
Updating & Debugging
How updates work
When you deploy a byte-different sw.js, the browser installs the new version but keeps it waiting until every tab controlled by the old worker closes โ so two versions never run at once. Calling self.skipWaiting() in install plus clients.claim() in activate promotes the new worker immediately. Because caches are keyed by name, bumping the version string is what triggers a real refresh:
const VERSION = 'v3';
const CACHE = `shell-${VERSION}`; // new name โ activate cleans up shell-v2
โ ๏ธ skipWaiting can surprise users
Forcing an update mid-session can swap assets under a page that expects the old ones. A gentler pattern: detect the waiting worker, show a "New version available โ Reload" toast, and only call skipWaiting() when the user clicks.
Debugging in DevTools
- Application โ Service Workers: tick Update on reload during development so you always run the latest file.
- Bypass for network: temporarily ignore the worker to compare against the live network.
- Application โ Cache Storage: inspect exactly what's cached and clear it.
- Network โ Offline: simulate no connection to test your fallbacks.
Hands-on Exercise
๐๏ธ Build an offline-capable shell
Objective: Write a service worker that precaches a small app shell, serves it offline, and routes API calls network-first.
Instructions:
- Create
sw.js. Ininstall, openshell-v1andaddAllyour HTML, CSS, JS, and an/offline.htmlfallback. - In
activate, delete any cache whose name isn'tshell-v1, thenclients.claim(). - In
fetch, route/api/requests network-first (fall back to cache), and everything else cache-first. - Register the worker from your page after
load. - In DevTools, go offline and reload โ the shell should still render. Then bump the cache to
shell-v2and confirm the old cache is deleted on activate.
๐ก Hint
If offline reload still fails, confirm the exact URLs you cached match the ones the page requests (a missing leading /, or caching /index.html but navigating to /, is the usual cause). For navigation requests, fall back to caches.match('/offline.html') when both network and cache miss.
โ Reference solution
const CACHE = 'shell-v1';
const SHELL = ['/', '/index.html', '/styles/main.css', '/app.js', '/offline.html'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(request, copy));
return res;
})
.catch(() => caches.match(request))
);
return;
}
event.respondWith(
caches.match(request).then((cached) =>
cached ??
fetch(request).catch(() =>
request.mode === 'navigate' ? caches.match('/offline.html') : undefined
)
)
);
});
๐ฏ Quick Quiz
Question 1: Which lifecycle event is the right place to precache the app shell?
Question 2: Why must you clone() a response before caching it?
Question 3: For a checkout POST that must never be served from cache, which strategy fits?
Summary & Quiz
๐ Key Takeaways
- A service worker is a terminable, DOM-less background proxy on a secure origin.
- The lifecycle is register โ install โ activate โ fetch; precache in install, clean up in activate.
- The Cache API stores
Request/Responsepairs โ alwaysclone()before caching. - Pick a strategy per resource: cache-first, network-first, stale-while-revalidate, cache-only, network-only.
- Background sync retries queued writes; push re-engages users; version your cache names to control updates.
๐ Further Reading
- MDN โ Service Worker API
- web.dev โ Learn PWA: Service workers
- Workbox documentation
- web.dev โ The Offline Cookbook
๐ What's Next?
You can now cache and serve requests. The final piece is design: treating offline as the default rather than an error. Next we cover offline-first strategies โ client storage, sync queues, and conflict resolution.
๐ Nice work!
The engine runs. Now let's design apps that assume the network is optional.