I Shipped a Fix. My Service Worker Kept Serving the Bug.
Three days after a critical fix went live, the support queue still had tickets coming in — all describing the exact bug you'd already closed. Every reply said the same thing: "I refreshed, it's still broken." A few said
Three days after a critical fix went live, the support queue still had tickets coming in — all describing the exact bug you'd already closed. Every reply said the same thing: "I refreshed, it's still broken." A few said they'd refreshed twice.
You pull up the deployed bundle. The fix is there, served from the server, timestamped correctly. You open the site in an incognito window and it works. You open it in your own regular browser, the one you've had open all week, and there's the bug — your bug, the one you already fixed, running code from four days ago.
Nothing about your deploy was wrong. The thing lying to your users was sitting in their browser, and it was doing exactly what you told it to do.
The obvious fix that doesn't fix it
The instinct is "tell them to hard-refresh." Ctrl+Shift+R, clear cache, the works. It helps some of your users and does nothing for the rest, because a service worker isn't the browser's HTTP cache — it's a script that intercepts every fetch on your origin and decides, in your code, what to answer with. A normal reload doesn't bypass it. A hard refresh sometimes does, depending on browser and whether the service worker itself is also cached. Telling users to fight their own browser is not a fix; it's a workaround you're asking thousands of people to perform by hand, repeatedly, every time you ship.
The real question isn't "how do we get users to clear their cache." It's "why didn't the new version take over automatically" — and that's a lifecycle question, not a caching question.
What's actually happening
A cache-first strategy looks like this, and it's genuinely the right choice for a lot of assets:
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
});
Check the cache. If it's there, serve it — no network round trip, instant response, works offline. That's the entire appeal of a service worker for a performance-conscious team, and it's why this pattern shows up in so many tutorials. The trap is what "cache-first" means for the one file that tells the browser everything else changed: if index.html or your main JS bundle is cache-first too, the browser never even asks the network whether a new version exists. It's handed the old answer before the question can be asked.
So you deploy a new service worker with a fix for this — and it still doesn't help, for a second, separate reason: a new service worker doesn't take over the instant it's installed. The lifecycle is install → waiting → activate, and by design, a waiting service worker sits idle as long as any tab is still controlled by the old one. Your users don't close all their tabs. Some of them haven't closed that tab since Monday. So the new worker waits, and waits, and the old one keeps answering every request with cache-first confidence.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
The two lines that actually fix it
Getting a new version to take over immediately is two separate opt-ins, and skipping either one leaves the bug half-fixed:
self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting()); // don't wait for old tabs to close
});
self.addEventListener("activate", (event) => {
event.waitUntil(clients.claim()); // take control of open tabs right now
});
skipWaiting() moves the new worker out of "waiting" the moment it finishes installing, instead of parking until every old tab closes. clients.claim() is the other half: without it, a freshly-activated worker still only controls tabs that load after it activates — any tab that was already open keeps talking to whichever worker controlled it when it loaded. Call both, and a currently-open tab's very next fetch is answered by the new worker.
That solves "the new version never takes over." It doesn't solve the first problem — a cache-first index.html or main bundle will still serve whatever that new worker finds in its cache, and if the install step cached the old files before the fetch handler takes over, you've just made the stale response arrive faster. The fix there is matching the strategy to the file:
- HTML and anything that names your entry bundle — network-first, or no caching at all. These are cheap to fetch and they're the one place "slightly stale" becomes "actively wrong."
-
Fingerprinted, hashed assets (
app.a3f91c.js) — cache-first is correct and free. The filename changes when the content does, so there's no staleness to worry about; the old file and the new file are simply different URLs. - Everything in between (an API response, a non-hashed icon) — stale-while-revalidate: answer instantly from cache, and kick off a network fetch in the background that updates the cache for next time.
const CACHE_NAME = "app-cache-v7"; // bump this every deploy
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
});
That version bump is the detail cache-first tutorials leave out most often. Called the way the fetch handler above calls it — with no cacheName option — caches.match() doesn't just check the cache you're currently writing to; it searches every cache under your origin, oldest first, and returns the first match it finds. Skip the activate-handler cleanup and a stale, un-deleted app-cache-v6 can still win that search over the app-cache-v7 entry sitting right next to it, even though the newer cache already has the file you just deployed. Deleting the old cache on activate isn't just tidiness — it's what guarantees caches.match() has nothing stale left to find.
Why this isn't a service worker bug
None of this is the API misbehaving. A service worker that keeps serving cached content after a deploy is doing precisely what cache-first plus "wait for every tab to close" plus "never evict the old cache" adds up to. The lifecycle exists so a new version can't yank state out from under a tab mid-interaction — that's the feature, not an oversight. The bug is in treating "cache it" as a complete caching strategy, when the strategy actually has three separate knobs: what gets cached, when the new worker takes over, and when the old cache gets deleted. Ship a fix to only one of those and the other two will happily keep serving yesterday's bug to someone who is absolutely certain they refreshed.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Next time a user says "I refreshed and it's still broken," the first question isn't "did you refresh hard enough." It's which of your three knobs you forgot to turn.
What's the longest a stale deploy has haunted one of your users before someone thought to check the service worker?
📚 Read next
- Your Timer Doesn't Know You Tabbed Away
- Next.js Cache Components Explained (with Cheat Sheet)
- Stop Debouncing Resize. The Browser Already Watches It.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.