Service Worker Lifecycle: Why Your PWA Update Isn't Working
skipWaiting() doesn't mean instant. clients.claim() has timing gotchas. First visits don't use your SW. Master the edge cases to build reliable PWAs.
Your Service Worker update isn't working. Users complain about stale content. You've read
the docs, called skipWaiting(), and still—nothing. The Service Worker lifecycle
is deceptively complex, with edge cases that catch even experienced developers.
The Lifecycle, Quickly
Service Workers move through states: installing → waiting → activating → activated. The critical insight: a new SW won't activate until all tabs using the old SW are closed.
// Registration triggers the lifecycle
navigator.serviceWorker.register('/sw.js');
// Service Worker states
'installing' → downloading/parsing
'installed' → waiting for old SW to release control
'activating' → taking over, running activate event
'activated' → controlling pages, handling fetch eventsEdge Case 1: skipWaiting() Doesn't Mean Instant
skipWaiting() skips the waiting phase, but it doesn't make the new SW
control pages immediately:
// In service-worker.js
self.addEventListener('install', (event) => {
self.skipWaiting(); // Skip waiting phase
});
// What happens:
// 1. New SW installs
// 2. skipWaiting() → immediately moves to 'activating'
// 3. Activate event runs
// 4. SW is 'activated'...
// 5. BUT existing pages still controlled by OLD SW until refresh!The new SW is active, but pages opened before the update still use the old one. They won't switch until they reload or navigate away.
Edge Case 2: clients.claim() Timing
clients.claim() makes the activated SW take control of existing pages
without requiring a refresh:
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});
// Now existing pages get the new SW immediately
But there's a catch: if you call clients.claim() in the activate event
without event.waitUntil(), the SW might deactivate before claim completes:
// ❌ Race condition
self.addEventListener('activate', (event) => {
clients.claim(); // Might not complete!
});
// ✅ Proper usage
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});Edge Case 3: First Visit Doesn't Use SW
On a user's first visit, the Service Worker installs but doesn't control the page:
// First visit timeline:
1. User visits site for the first time
2. Page loads from network (no SW yet)
3. SW registers and installs
4. SW activates
5. Page is NOT controlled by SW (it loaded before SW existed)
6. User refreshes → NOW the page is controlled
Without clients.claim(), the first page load never benefits from the SW.
This is why you see "refresh for offline support" messages.
// To control first-visit pages:
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
// First-visit pages now controlled
});Edge Case 4: The Dangerous skipWaiting + claim Combo
Combining skipWaiting() and clients.claim() can break your app:
// sw-v1.js caches /app.js (version 1)
// User has tab open with app.js v1 loaded
// sw-v2.js deploys with skipWaiting + claim
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(clients.claim()));
// What happens:
// 1. sw-v2 installs, skipWaiting → activates immediately
// 2. clients.claim() → takes control of existing tab
// 3. Tab now fetches through sw-v2's cache
// 4. User clicks button that fetches /api/data
// 5. sw-v2 returns /api/data from its cache (or new network response)
// 6. BUT the page is still running app.js v1!
// 7. If APIs changed between v1→v2, app breaksThe page HTML/JS is from v1, but fetch requests go through v2. This mismatch causes subtle bugs that are hard to reproduce and debug.
Safe Update Pattern
// Option 1: Prompt user to refresh
navigator.serviceWorker.addEventListener('controllerchange', () => {
// New SW took control
if (confirm('New version available. Reload?')) {
window.location.reload();
}
});
// Option 2: Only skipWaiting when user consents
// In your page:
function applyUpdate() {
navigator.serviceWorker.ready.then((registration) => {
if (registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
}
// In service-worker.js:
self.addEventListener('message', (event) => {
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});Edge Case 5: Scope Conflicts
Service Workers control pages within their scope. Nested scopes create hierarchy:
// Two SWs registered:
navigator.serviceWorker.register('/sw.js'); // Controls /
navigator.serviceWorker.register('/blog/sw.js'); // Controls /blog/
// /blog/post/1 → controlled by /blog/sw.js (more specific)
// /about → controlled by /sw.js
// /blog → controlled by /blog/sw.js
The gotcha: if /blog/sw.js unregisters, pages under /blog/
aren't automatically controlled by /sw.js—they become uncontrolled until refresh.
Edge Case 6: Update Check Timing
The browser checks for SW updates on navigation, but with limits:
// Update checks happen:
// - On navigation to a controlled page
// - On functional events (push, sync) if >24h since last check
// - When registration.update() is called manually
// But NOT:
// - While a fetch event is being handled
// - If checked <24 hours ago (browser-dependent)
// - If the page is a soft navigation (SPA route change)For SPAs, manually trigger update checks on route changes:
// In your SPA router
router.afterEach(() => {
navigator.serviceWorker.ready.then((registration) => {
registration.update();
});
});Edge Case 7: Byte-for-Byte Comparison
Browsers compare the new SW file byte-for-byte with the cached one. A single byte difference triggers an update:
// sw.js - Adding a version comment forces update
const VERSION = 'v2.0.1'; // Change this to force update
// Or use build hash in filename
// sw.abc123.js → sw.def456.jsGotcha: if your build process minifies differently each time (whitespace, variable names), you might trigger unnecessary updates. Use consistent build output or explicit versioning.
Edge Case 8: importScripts Caching
Files loaded with importScripts() are cached separately:
// sw.js
importScripts('/sw-utils.js');
// If sw-utils.js changes but sw.js doesn't,
// the browser might not detect an update!
// Solution: Include version in imported file names
importScripts('/sw-utils.v2.js');
// Or reference a hash
importScripts('/sw-utils.abc123.js');Debugging Service Workers
// Check SW state
navigator.serviceWorker.ready.then((reg) => {
console.log('Active SW:', reg.active?.state);
console.log('Waiting SW:', reg.waiting?.state);
console.log('Installing SW:', reg.installing?.state);
});
// Listen for state changes
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('Controller changed!');
});
// In DevTools:
// Application → Service Workers → "Update on reload" for developmentKey Takeaways
skipWaiting()activates the SW but doesn't control existing pages—addclients.claim()- The skipWaiting + claim combo can cause version mismatches—prompt users to reload instead
- First-visit pages aren't controlled unless you use
clients.claim() - SPAs need manual
registration.update()calls on route changes - Changes to imported scripts might not trigger updates—version your imports
- Use "Update on reload" in DevTools during development to avoid lifecycle headaches
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement