Service Worker Lifecycle
Why Service Workers Feel Confusing
Most web APIs are simple: call a function, get a result. Service workers are different. They have a lifecycle — a state machine that determines when your code runs, when it updates, and when it takes control of the page. Ignore the lifecycle and your cache never updates. Misunderstand it and your users get stuck on stale versions forever.
Here's the thing most tutorials skip: the lifecycle exists to protect users. The browser is extremely careful about when it lets new code control network requests. One buggy service worker could brick an entire site. So the browser forces an orderly transition between versions.
Once you understand why each step exists, the lifecycle becomes intuitive.
A service worker is like a new employee replacing an old one. The new hire (installing worker) shows up and prepares (installs caches). But the old employee (active worker) keeps working until all their current clients leave (all tabs close). Only then does the new hire take over (activate). You can force an immediate takeover (skipWaiting), but that means the new employee serves clients who started with the old one — potentially causing inconsistencies.
The Lifecycle: Step by Step
1. Registration
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('SW registered:', registration.scope);
}
Registration tells the browser where your service worker script lives and what URL scope it controls. The browser downloads sw.js, parses it, and if valid, begins installation.
Scope determines which pages the service worker controls. A worker registered with scope /app/ only intercepts fetches from pages under /app/. The default scope is the directory containing the SW script.
Register your service worker after the page loads (window.onload or in a useEffect). Registration triggers a network fetch and script parse — you don't want that competing with your page's critical resources.
2. Install Event
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then((cache) => {
return cache.addAll([
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.svg',
]);
})
);
});
The install event fires once per service worker version. This is where you precache assets — the resources your app needs to work offline.
event.waitUntil() tells the browser: "Don't finish installation until this Promise resolves." If the Promise rejects (e.g., a cache.addAll fails because one URL 404'd), the entire installation fails and the worker is discarded. Next page load, the browser tries again.
3. Waiting State
After installation succeeds, the new service worker enters a waiting state. It does not activate yet. Why? Because the old service worker might still be controlling open tabs. Activating the new one mid-session could break those tabs — they loaded with one set of cached assets and would suddenly get a different set.
The new worker waits until all tabs controlled by the old worker are closed. Then it activates.
You can see waiting workers in Chrome DevTools under Application > Service Workers.
4. Activate Event
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== 'v2')
.map((name) => caches.delete(name))
);
})
);
});
Activation is your chance to clean up old caches. When the new worker activates, old cache versions are stale. Delete them here.
event.waitUntil() works the same as in install — the browser waits for cleanup to finish before the worker starts intercepting fetches.
5. Fetch Event
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
Once active, the service worker intercepts every network request within its scope. event.respondWith() lets you return a custom response — from cache, from the network, or synthesized from nothing.
This is where caching strategies live. We cover strategies in depth in the Workbox topic.
skipWaiting and clients.claim
skipWaiting: Force Immediate Activation
self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(precacheAssets());
});
skipWaiting() tells the browser: "Don't wait for old tabs to close. Activate me now." The new worker jumps from 'waiting' to 'active' immediately.
clients.claim: Take Control of Existing Tabs
self.addEventListener('activate', (event) => {
event.waitUntil(
clients.claim()
);
});
Normally, a service worker only controls pages that loaded after it activated. clients.claim() forces the new worker to take control of all existing tabs immediately — even ones that loaded before it was registered.
The skipWaiting + clients.claim Combo
Together, these two methods give you "update everything immediately" behavior:
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});
The skipWaiting + clients.claim combo is powerful but dangerous. Consider: a user loads your page with service worker v1. While browsing, v2 installs and immediately takes control. The page was built expecting v1's cached assets, but now v2 is serving different responses. If your cache schema changed between versions, the page could break. Only use this combo when your updates are backwards-compatible.
The Update Flow
How does the browser know there's a new version? Every time a user navigates to a page in scope, the browser re-fetches the service worker script and does a byte-by-byte comparison:
- User navigates to page in scope
- Browser fetches
sw.js(respecting HTTP cache headers, but with a 24-hour max age cap) - If the file changed (even one byte different), the browser treats it as a new version
- New version enters the install/wait/activate lifecycle
// Force update check manually
const registration = await navigator.serviceWorker.getRegistration();
await registration.update();
Browsers cap HTTP caching of service worker scripts at 24 hours, regardless of Cache-Control headers. This ensures users get updates within a day even if you set aggressive cache headers. For imported scripts (via importScripts), the same cap applies.
Notifying Users of Updates
navigator.serviceWorker.register('/sw.js').then((registration) => {
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBanner('New version available. Refresh to update.');
}
});
});
});
This pattern detects when a new worker is installed and waiting, then shows a UI prompt. When the user clicks "refresh," you call skipWaiting via postMessage and reload:
refreshButton.addEventListener('click', () => {
newWorker.postMessage({ type: 'SKIP_WAITING' });
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
In the service worker:
self.addEventListener('message', (event) => {
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
Debugging Service Workers
Chrome DevTools
Open Application > Service Workers to see:
- Current state (installing, waiting, active)
- Whether
skipWaitingis needed (click the link to force it) - Scope
- Source link (click to debug)
Common Debugging Techniques
// Check if a service worker is controlling the page
console.log('Controller:', navigator.serviceWorker.controller);
// List all registrations
const registrations = await navigator.serviceWorker.getRegistrations();
registrations.forEach((r) => console.log(r.scope, r.active?.state));
// Unregister everything (nuclear option for debugging)
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((r) => r.unregister()));
In Chrome DevTools, check "Bypass for network" under Application > Service Workers during development. This disables the service worker's fetch handler so you always get fresh resources from the network. Remember to uncheck it when testing offline behavior.
Common Lifecycle Gotchas
| What developers do | What they should do |
|---|---|
| Expecting a new service worker to take effect immediately after deployment The lifecycle protects users from mid-session breakage. A page loaded with v1 assets should not suddenly get v2 responses. | The new SW installs but waits until all old tabs close before activating. Use skipWaiting for immediate activation, or show an update prompt. |
| Caching opaque responses (from no-cors requests) in the install event Opaque responses (from cross-origin requests without CORS) have a status of 0 and no accessible body. cache.addAll treats a 0 status as a failure, but cache.put does not. You could cache an error response and serve it forever. | Only cache responses where you can verify the status. Opaque responses may be errors that look like successes. |
| Not cleaning up old caches during activation Old caches persist until explicitly deleted. If you change cache names between versions but never clean up, storage usage grows with every deployment. | Delete caches from previous versions in the activate event to free storage and prevent serving stale assets |
| Registering the service worker before the page finishes loading Service worker registration triggers a network fetch for the SW script. During initial page load, that competes with critical resources (CSS, JS, fonts). Register after load to prioritize the user's first paint. | Wait for the 'load' event before registering to avoid competing for bandwidth during critical resource loading |
Service Worker Scope Deep Dive
Scope determines which pages the service worker controls and which fetch requests it intercepts:
// Controls all pages under /
navigator.serviceWorker.register('/sw.js');
// Controls only pages under /app/
navigator.serviceWorker.register('/sw.js', { scope: '/app/' });
A service worker at /app/sw.js can only control pages under /app/ by default. You cannot set a scope broader than the script's location unless the server sends a Service-Worker-Allowed header:
Service-Worker-Allowed: /
- 1The lifecycle is register, install, wait, activate, fetch — each step exists to protect users from broken updates
- 2A new SW waits until all tabs using the old SW close before activating — use skipWaiting() to bypass this
- 3clients.claim() makes a newly activated SW control existing tabs immediately
- 4Clean up old caches in the activate event to prevent storage bloat
- 5The browser byte-compares SW scripts on every navigation (24-hour cache cap) to detect updates