Service Worker skipWaiting requires update discipline, not instant reloads
Force-activating a new service worker can cause data loss. Here's how to do it safely.
Why skipWaiting() Matters for PWA Updates
Service workers have a lifecycle that intentionally delays new versions from taking over: an installing worker becomes waiting only after it finishes installing, and users must close all tabs or manually trigger activation for it to become active.
This design prevents abrupt mid-session breakage, but it also slows down urgent or low-risk updates. The skipWaiting() method bridges this gap by forcing the waiting worker to activate immediately.
MDN's service worker lifecycle documentation explains that skipWaiting() is not automatic—it requires a deliberate call, often paired with user notice.
How the Service Worker Lifecycle Works
Three States
- Installing: The worker is downloading and parsing assets. Scripts may
self.skipWaiting()here. - Waiting: The worker has installed successfully but is waiting for all tabs to close before activating.
- Active: The worker is controlling pages and handling fetch events.
Automatic vs. Manual Activation
- Manual default: Users must close all open tabs of your origin for the waiting worker to activate.
- skipWaiting(): Forces activation immediately, even with open tabs.
Implementing skipWaiting() Correctly
Step 1: Call skipWaiting() in the Worker
// sw.js
self.addEventListener('install', (event) => {
// Pre-cache critical assets...
event.waitUntil(
caches.open('v1').then((cache) => {
return cache.addAll([
'/',
'/offline.html',
'/styles.css',
]);
})
);
});
// The activation behavior
self.addEventListener('activate', (event) => {
// Clean up old caches
event.waitUntil(
caches.keys().then((cacheKeys) => {
return Promise.all(
cacheKeys
.filter((key) => key.startsWith('v') && key !== 'v1')
.map((key) => caches.delete(key))
);
})
);
});
// If you want immediate activation (user-controlled):
self.addEventListener('install', (event) => {
const skipWaitingPromise = self.skipWaiting();
event.waitUntil(skipWaitingPromise);
});Critical: Always call skipWaiting() inside an event listener (typically install), wrapped in event.waitUntil(). Never call it arbitrarily during fetch handling or other events.
Step 2: Notify Users in the Client
if ('serviceWorker' in navigator) {
let registration;
navigator.serviceWorker.register('/sw.js').then((reg) => {
registration = reg;
// Listen for a new worker entering the waiting state
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// A new version is waiting
showUpdateAvailable();
}
});
});
});
}
function showUpdateAvailable() {
const updateButton = document.createElement('button');
updateButton.textContent = 'Update now';
updateButton.onclick = () => {
if (registration && registration.waiting) {
// Post a message to the waiting worker to skip waiting
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
};
// Show a non-intrusive toast/banner
document.body.appendChild(createUpdateUI(updateButton));
}
// Listen for controller change (the worker activated)
navigator.serviceWorker.addEventListener('controllerchange', () => {
// Ideally, offer a "Refresh" option or auto-reload after user confirms
window.location.reload();
});Step 3: Handle skipWaiting Message in the Worker
// sw.js
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});Why a message? Calling skipWaiting() directly from the client isn't possible—you must send a message to the worker.
Common Pitfalls
1. Calling skipWaiting() Automatically Without User Notice
Calling skipWaiting() for every update can:
- Break mid-session form submissions or transactions
- Invalidate client-side state (indexedDB, local storage assumptions)
- Cause layout shifts or errors with race conditions
Guideline: Only skip for low-risk updates (minor CSS tweaks, HTML template changes) or with explicit user consent.
2. Failing to Clean Up Old Caches
When a new worker activates, you must remove cache entries from previous versions to avoid storage bloat and stale artifacts:
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== currentCacheName) {
return caches.delete(cacheName);
}
})
);
})
);
});3. Race Conditions During Activation
If clients don't reload immediately after controllerchange, they may be controlled by the new worker but still hold onto old assumptions:
- Workaround: The safest approach is to reload or offer a "Reload" prompt immediately.
4. Ignoring naviationPreload
For PWAs heavily dependent on network responses, skipWaiting() without co-ordinating navigation preload can cause duplicate requests or failed handling:
// sw.js
navigationPreload.enable('/lookup?src=' + self.registration.scope);Testing with navigator.serviceWorker.ready in clients reduces race risk.
##-Type-Based Update Strategies
| Update Type | Use skipWaiting()? | Rationale | |-------------|-------------------|-----------| | Critical bug fix | ✅ | Forcing immediately improves user experience | | Low-risk CSS/HTML tweak | ✅ | Breaking risk is minimal | | JavaScript logic change | ⚠️ | May break in-flight operations | | Asset bundle change (JS/CSS) | ✅ | Users benefit immediately; coordinates via cache names | | IndexedDB schema change | ⚠️ | Ensure migration path | | Offline strategy shift | ⚠️ | Test thoroughly before skipping |
User Experience Patterns
Pattern 1: "Update Available" Toast
function createUpdateToast() {
const toast = document.createElement('div');
toast.className = 'update-toast';
toast.innerHTML = `
<span>A new version is available.</span>
<button id="updateNow">Refresh</button>
`;
document.body.appendChild(toast);
toast.querySelector('#updateNow').onclick = () => {
if (registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
};
}
// Show only once per session
const updateShown = sessionStorage.getItem('updateShown');
if (!updateShown) {
sessionStorage.setItem('updateShown', 'true');
createUpdateToast();
}Pattern 2: In-App Update Banner
For content-focused PWAs:
- Add a small banner at the top/bottom
- Include "Dismiss" and "Update" buttons
- Persist dismissal via LocalStorage
Pattern 3: Silent Refresh for Low-Risk Updates
For non-transactional apps:
if (registration.waiting && currentVersion === 'newStableVersion') {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Allow clients to reload automatically after controllerchange
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}When to avoid: E-commerce, banking, form-heavy apps.
Verification Checklist
Before deploying automatic skipWaiting():
Worker Side
- [ ]
skipWaiting()called inside an event listener and wrapped inevent.waitUntil() - [ ] Message handler listens for
SKIP_WAITINGviapostMessage() - [ ]
activateevent cleans up old caches - [ ] Cache names are versioned (
v1,v2) to distinguish assets
Client Side
- [ ]
updatefoundlistener detects new workers - [ ] User is notified via non-intrusive UI (toast, banner)
- [ ]
postMessageto waiting worker when user accepts update - [ ]
controllerchangelistener triggers page reload or informs users manually
Testing
- [ ] Test with open tabs: Verify
controllerchangefires without requiring tab closure - [ ] Test cache cleanup: Confirm old versions don't accumulate
- [ ] Test mid-session form submission: Ensure data isn't lost during update
- [ ] Test offline mode: Verify new version caches correctly and works offline
For Different App Types
E-Commerce
- ❌ Avoid automatic skipWaiting.
- ✅ Use user-controlled refresh with cart preservation.
- ✅ Coordinated indexedDB migrations.
Content/News
- ✅ Aggressive updates improve freshness.
- ✅ Use silent refresh for low-risk changes.
- ⚠️ Be mindful of reading state (article scroll position).
Productivity/Tools
- ⚠️ Updates often affect logic; require user confirmation.
- ✅ Preserve document state (e.g., draft content) across reload.
Gaming
- ⚠️ Update mid-session harmful; require session-end reload.
- ✅ Use "Update ready, play current game" messaging.
Browser Variations
- Chrome:
skipWaiting()widely supported; inspect behavior in DevTools Application tab. - Edge:chromium-based; matches Chrome.
- Firefox: Supports
skipWaiting; but some devcache quirks exist. - Safari: Service workers available, but activation lifecycle differs slightly; test thoroughly.
Tools and Debugging
DevTools Service Worker Panel
- Inspect
installing,waiting, andactiveworkers. - Trigger
skipWaitingmanually via the UI for testing. - View logs from the worker via
console.log().
Workbox Guidance
If using Workbox, their skipWaiting() setup simplifies lifecycle handling:
workbox.core.clientsClaim();
workbox.precaching.precacheAndRoute([...]);
// In the worker:
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
});Workbox's clientsClaim() also helps pages be controlled immediately after activation.
Next Steps
- Audit your current service worker update flow: Is it manual or automatic?
- Determine your app's risk profile: How many mid-session operations break?
- Design an update UX pattern: Toast? Banner? In-app modal?
- Implement the message-passing pattern between client and worker.
- Test on real devices with multiple tabs open.
The goal isn't speed alone—it's delivering updates when they matter without disrupting user work. skipWaiting() is a tool, not a default.