The essentials
Quick reference
One focused task per row. Jump to the related section for complete, working examples.
| Use | Syntax | Examples |
|---|---|---|
| Register within a scope | navigator.serviceWorker.register('/app/sw.js', { scope: '/app/' }) | View examples |
| Wait for an active worker | const registration = await navigator.serviceWorker.ready | View examples |
| Check current control | const controlled = navigator.serviceWorker.controller !== null | View examples |
| Extend installation | event.waitUntil(precachePromise) | View examples |
| Open a named cache | const cache = await caches.open('app-shell-v4') | View examples |
| Precache required URLs | await cache.addAll(['/app/', '/app/offline.html']) | View examples |
| Extend activation | event.waitUntil(deleteOldCaches()) | View examples |
| Claim matching clients | await self.clients.claim() | View examples |
| Activate a waiting update | registration.waiting.postMessage({ type: 'ACTIVATE_UPDATE' }) | View examples |
| Provide a fetch response | event.respondWith(responsePromise) | View examples |
| Find a cached response | const cached = await caches.match(event.request) | View examples |
| Store a response clone | await cache.put(request, response.clone()) | View examples |
| Check for an update | await registration.update() | View examples |
| Observe new control | navigator.serviceWorker.addEventListener('controllerchange', reloadOnce) | View examples |
A service worker is an event-driven network proxy associated with an origin and scope, not a permanently running background process. It can intercept controlled clients' requests and serve explicitly stored Request/Response pairs from the Cache API. Reliable offline behavior requires a secure deployment, narrow routing rules, versioned caches, bounded storage, intentional fallbacks, and an update protocol that prevents old pages from talking to incompatible new code.
Step by step
Detailed examples
Register securely and keep scope intentional
Service workers require a trustworthy context, normally HTTPS, with localhost treated specially for development. The default maximum scope is the worker script's directory; placing /app/sw.js beside the application naturally permits /app/. A broader scope requires a Service-Worker-Allowed response header and should be a conscious server decision. Registration resolving means the registration exists, not that this first page is controlled; ready waits for an active registration, while controller reveals actual control.
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/app/sw.js', {
scope: '/app/'
});
console.log('Registered scope:', registration.scope);
await navigator.serviceWorker.ready;
console.log('Active registration is ready');
console.log('This page is controlled:', navigator.serviceWorker.controller !== null);
} catch (error) {
console.error('Service worker registration failed', error);
}
} Make installation an atomic application-shell gate
Register lifecycle listeners at the worker's top level because the browser may stop and restart its global scope between events. install is the place to cache a small, versioned set of resources required for the offline shell. Pass the complete promise to waitUntil: if a required fetch or cache write rejects, the new worker does not install and the previous version remains available. Keep optional or cross-origin resources out of the critical precache transaction.
// /app/sw.js
const VERSION = 'v4';
const PRECACHE = `app-precache-${VERSION}`;
const REQUIRED = [
'/app/',
'/app/styles.css',
'/app/app.js',
'/app/offline.html'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(PRECACHE).then(cache => cache.addAll(REQUIRED))
);
}); Activate compatible code and remove only owned caches
A successfully installed update normally waits until pages using the old worker close. During activate, delete obsolete caches owned by this application and preserve unrelated names from the same origin. clients.claim can control eligible existing pages immediately, and skipWaiting can replace an active worker immediately; both are useful only when the new worker remains protocol-compatible with already loaded pages. Prefer prompting the user before activating a breaking update.
const OWNED_PREFIX = 'app-';
const CURRENT = new Set([PRECACHE, `app-runtime-${VERSION}`]);
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(names
.filter(name => name.startsWith(OWNED_PREFIX) && !CURRENT.has(name))
.map(name => caches.delete(name)));
await self.clients.claim();
})());
});
self.addEventListener('message', event => {
if (event.data?.type === 'ACTIVATE_UPDATE') {
event.waitUntil(self.skipWaiting());
}
}); Route requests before choosing a caching strategy
Call respondWith during fetch-event dispatch, and ignore requests your worker does not explicitly own. A network-first navigation can return fresh HTML and fall back to a cached page when offline. Versioned same-origin static assets often suit stale-while-revalidate: return a cached response immediately while a successful network response refreshes the runtime cache. fetch resolves for HTTP errors, so check response.ok before caching; clone a response because bodies are streams that can be consumed only once.
const RUNTIME = `app-runtime-${VERSION}`;
async function storeSuccessful(request, response) {
if (!response.ok) return;
try {
const cache = await caches.open(RUNTIME);
await cache.put(request, response.clone());
} catch (error) {
console.warn('Runtime cache write failed', error);
}
}
self.addEventListener('fetch', event => {
const request = event.request;
const url = new URL(request.url);
if (request.mode === 'navigate') {
event.respondWith((async () => {
try {
const response = await fetch(request);
await storeSuccessful(request, response);
return response;
} catch {
return (await caches.match(request))
?? (await caches.match('/app/offline.html'))
?? new Response('Offline', { status: 503, headers: { 'Content-Type': 'text/plain' } });
}
})());
return;
}
const staticAsset = request.method === 'GET'
&& url.origin === self.location.origin
&& ['style', 'script', 'image'].includes(request.destination);
if (!staticAsset) return;
const networkUpdate = fetch(request).then(async response => {
await storeSuccessful(request, response);
return response;
});
event.waitUntil(networkUpdate.then(() => undefined, () => undefined));
event.respondWith(caches.match(request).then(cached => cached ?? networkUpdate));
}); Ship observable updates and bound cached data
Cache entries do not become fresh merely because the network resource changed, and the Cache API does not implement an application expiration policy for you. Version immutable shell assets, cap or expire runtime entries, and avoid caching personalized or sensitive responses unless the policy is deliberate. Keep the service worker script at a stable URL with revalidation-friendly HTTP headers. update asks for a check; when an installed worker is waiting, let the page notify the user, message that exact worker, then reload once after controllerchange.
const registration = await navigator.serviceWorker.getRegistration('/app/');
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return;
reloading = true;
location.reload();
});
if (registration) {
registration.addEventListener('updatefound', () => {
const candidate = registration.installing;
candidate?.addEventListener('statechange', () => {
if (candidate.state === 'installed' && navigator.serviceWorker.controller) {
document.querySelector('#update-notice').hidden = false;
}
});
});
document.querySelector('#apply-update').addEventListener('click', () => {
registration.waiting?.postMessage({ type: 'ACTIVATE_UPDATE' });
});
await registration.update();
} Sources and further reading
References
Authoritative documentation used to verify and expand this cheat sheet.
Help us improve
Found a typo or missing example?
Tell us what would make this cheat sheet clearer, more complete, or more useful.



