// apis · Web Platform Advent #4
Service Workers: caching, offline and updates explained
A practical guide to service workers — registering one, the install/activate lifecycle, the Cache API, serving a page offline, and shipping updates safely.
A service worker is a script the browser runs in the background, separate from your page. It sits between your app and the network like a programmable proxy: it can intercept requests, answer them from a cache, and keep your site working even when the user is offline. It is the foundation of Progressive Web Apps.
Two ground rules before anything else. A service worker only runs over HTTPS (or localhost during development), and it has no access to the DOM — it communicates with pages through events and messages, not by touching document.
Registering a service worker
You register the worker from your normal page script. Feature-detect first, then point the browser at the script file:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((reg) => console.log('Scope:', reg.scope))
.catch((err) => console.error('SW failed:', err));
});
} The file location matters: a worker at /sw.js controls the whole origin, while one at /app/sw.js only controls pages under /app/. That boundary is its scope.
The lifecycle: install and activate
A service worker has its own life independent of the page. The two events you care about most are install (fired once, a good place to pre-cache assets) and activate (a good place to clean up old caches):
const CACHE = 'site-v1';
const ASSETS = ['/', '/index.html', '/styles.css', '/app.js', '/offline.html'];
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)))
)
);
}); Calling event.waitUntil() tells the browser not to consider the phase finished until your promise settles — otherwise it might tear the worker down mid-task.
Intercepting requests with fetch
The fetch event is where the magic happens. You decide what every request returns. A common, safe strategy is cache-first with a network fallback:
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
}); To show a custom offline page when both the cache and the network fail, add a catch:
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).catch(() => caches.match('/offline.html'))
);
}); Caching strategies at a glance
| Strategy | Behaviour | Good for |
|---|---|---|
| Cache-first | Serve cache, fall back to network | Static assets (CSS, JS, fonts) |
| Network-first | Try network, fall back to cache | HTML, API data that changes |
| Stale-while-revalidate | Serve cache, update it in the background | Avatars, feeds, content lists |
Shipping updates without breaking users
When you change sw.js, the browser detects the new bytes and installs the new worker, but it stays waiting until every tab using the old one is closed. That prevents two versions running at once. To take over sooner, call self.skipWaiting() in install and clients.claim() in activate — but only do that when you are confident the new version is compatible with already-open pages.
Always bump the cache name (site-v1 → site-v2) when assets change. The activate handler above then deletes the stale cache, so users never get a mix of old and new files.
That is the whole core loop: register, pre-cache on install, clean up on activate, and answer requests on fetch. Start with a cache-first strategy for static files and a network-first one for your HTML, and you have a site that loads instantly on repeat visits and survives a dropped connection.