Back to articles
PWA July 1, 2026 5 min read

The Service Worker Bug That's Probably Breaking Your Users' Experience Right Now

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…

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:

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:

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:

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

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.

Need something like this built?

I work on full-stack web apps — backend systems, APIs, and the front-ends that sit on top. If this post was useful and you've got a project that needs it, I'd like to hear about it.

Want future posts like this?

No mailing list yet — for now, email me and I'll let you know when something new goes up.

Email me