When you push a hot‑fix to a PWA, you probably run through the same ritual: deploy, open an incognito window, verify the bug is gone, then get a ticket from a user who still sees the problem. You ask them to hit Refresh, they swear they did. You ask for a hard‑refresh (Ctrl+Shift+R), still nothing. Finally you tell them to clear the browser cache, and only then does the new code appear.
That isn’t a rare edge case – it’s the most common service‑worker gotcha in production. If you’ve ever built a PWA, odds are you’ve shipped at least one version that silently kept serving stale assets.
Why a plain "Refresh" never fixes the issue
A service worker is more than a static cache. Once it’s installed, it becomes a network proxy for every request the page makes, including the request for the HTML document itself. When you upload a fresh service-worker.js, the browser follows a very deliberate lifecycle:
- The new script is fetched in the background while the old worker continues to run.
- It is installed, but immediately goes into a "waiting" state.
- All open tabs stay attached to the old worker; the new one never intercepts their traffic.
- Activation only happens after every client controlled by the old worker is closed – a full tab close, not just a reload.
That last bullet is the trap. Most users keep a tab open for hours, refresh it, navigate away, or leave the browser running overnight. The waiting worker can sit there forever, serving the old bundle while the user thinks they’re on the newest version.
The lifecycle in practice
Think of the service worker as a tiny server that lives inside the browser. Its state machine looks roughly like this:
install→ script downloaded, caches populated.waiting→ ready, but blocked by existing clients.activate→ takes over, can claim clients.redundant→ old worker discarded.
Because the spec deliberately avoids breaking a user’s current session, it prefers safety over immediacy. The trade‑off is that developers must explicitly tell the worker to skip the waiting phase if they want an instant hand‑off.
Immediate activation – the code you need
The simplest way to force the new worker to become active as soon as it finishes installing is to call skipWaiting() during the install event and then claim any open pages in activate:
// service-worker.js
self.addEventListener('install', (event) => {
// Bypass the waiting state
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
// Take control of all open pages under this scope
event.waitUntil(self.clients.claim());
});
With those two lines, the new worker will replace the old one the moment the install finishes, regardless of how many tabs are still open.
But the page itself still needs a nudge
Skipping the waiting phase only changes future network requests. The HTML, CSS, and JavaScript that are already loaded in a tab stay exactly as they were. If you want the UI to reflect the fresh assets without forcing the user to manually reload, you have to listen for the controllerchange event on the navigator.serviceWorker object:
// main.js – runs in the page, not the worker
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return; // guard against multiple triggers
reloading = true;
window.location.reload();
});
When the new worker takes control, the browser fires controllerchange. The snippet above forces a single reload, guaranteeing that the page now runs against the latest cached files.
When an automatic reload is too aggressive
Force‑reloading every time a new worker appears can be jarring. Imagine a user halfway through a checkout flow or filling out a long form; a sudden page refresh will wipe their progress. A more user‑friendly pattern is to detect that an update is waiting and surface a small, dismissible banner:
// main.js – optional UI prompt
navigator.serviceWorker.getRegistration().then((reg) => {
if (!reg) return;
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Show a UI element asking the user to refresh
showUpdateBanner(); // implement this yourself
}
});
});
});
The banner can say something like "A new version is available. Refresh now to get the latest features." and include a button that calls window.location.reload() when the user clicks it. This approach respects the user’s current task while still giving them a clear path to the updated app.
How to test the scenario before it reaches real users
During development it’s easy to miss the bug because you’re constantly using hard‑refreshes, clearing storage, or opening a fresh incognito window – all of which bypass the waiting state. To reproduce the problem locally:
- Open your PWA in a normal tab (not incognito).
- Open DevTools → Application → Service Workers and uncheck "Update on reload".
- Deploy a change to
service-worker.js(e.g., bump a version constant). - Reload the page normally. You’ll notice the UI still reflects the old version.
- Open the Console and run
navigator.serviceWorker.controllerto confirm the old worker is still controlling the page. - Now either close the tab completely or call
self.skipWaiting()in the new worker and observe the immediate switch.
By deliberately disabling the auto‑update shortcut, you force the browser to follow the real lifecycle, exposing the stale‑cache bug before any user sees it.
Bottom line
- Service workers don’t swap on a simple page refresh. They wait for all controlled tabs to close.
skipWaiting()+clients.claim()give you an instant hand‑off, but they only affect future requests.- Listen for
controllerchangeif you want the current page to reload automatically. - Prefer a user‑prompt over a blind auto‑reload to avoid disrupting in‑flight interactions.
- Test with the waiting state enabled to catch the issue early.
If your PWA lacks any of the patterns above, there’s a good chance a slice of your audience is still seeing an outdated version, unaware that a fix exists. Adding the few lines of code shown here turns that hidden bug into a transparent, controllable update flow – and saves you from a flood of “the bug is still there” tickets.