A deploy goes out, the team confirms the new code is live on the server, and a user still reports seeing the old version hours later. Nobody touched their browser cache settings. The culprit, more often than not, is a service worker that's still faithfully serving assets from a previous install.
Service Workers Don't Update the Way You Might Expect
A service worker doesn't automatically take over as soon as a new version is deployed. The browser downloads the new script, but the currently active service worker keeps controlling open tabs until every tab running the old version is closed. A user who keeps a tab open across a deploy can genuinely be running your app on last week's cached assets while a colleague in a fresh tab gets the new build.
This isn't a bug in the browser. It's the deliberate lifecycle model service workers use to avoid yanking assets out from under a page mid-session. The catch is that most teams don't design for it, and end up confused when "the deploy is live" doesn't match what a specific user sees.
The skipWaiting Trap
A common fix is calling skipWaiting() in the service worker's install event, which forces the new worker to activate immediately instead of waiting for old tabs to close. This solves the stale-build problem, but introduces a new one: a tab that was mid-session can suddenly have its network requests handled by a service worker expecting a different version of the app's JavaScript, causing requests for assets that no longer exist.
The safer pattern pairs skipWaiting() with a prompt to the user, a small "a new version is available, refresh to update" banner, rather than silently swapping the active worker out from under a page that's still running.
This matters more for apps with long-lived sessions, a dashboard someone leaves open all day, than for content sites where most visits are short. If your users tend to keep tabs open for hours, budget real design time for how the update prompt behaves, since it's going to fire regularly rather than being an edge case.
Multiple Tabs Make This Worse
A single user with several tabs of your app open compounds the problem, because all of those tabs share the same service worker registration. Forcing an update in one tab can affect requests in flight from another tab the user hasn't even looked at recently. Testing with only a single tab open hides this entirely, and it's worth explicitly testing a multi-tab scenario before shipping any change to your update strategy.
Cache Versioning Inside the Service Worker Itself
Every service worker cache should be named with a version identifier baked in, and the worker's activate event should explicitly delete any cache whose name doesn't match the current version. Skipping this step is the single most common cause of a service worker serving genuinely old assets indefinitely, because nothing ever tells it the old cache is no longer valid.
MDN's service worker documentation walks through the install and activate lifecycle events in detail, including the cache cleanup pattern that should run on every activation.
Precaching vs. Runtime Caching
Precaching, bundling a specific list of assets into the cache at install time, guarantees those assets are available offline immediately, but it means every deploy needs a new precache manifest or the worker will keep serving the old list. Runtime caching, storing responses as they're fetched during normal use, adapts more naturally to changes but can leave gaps for assets a user hasn't visited yet.
Most real apps need both: precache the shell and critical assets, and runtime-cache everything else with a sensible expiry. web.dev's guidance on service worker caching strategies covers the tradeoffs between these approaches in more depth than fits here.
Getting this split wrong tends to fail quietly rather than loudly. An app that precaches too little ends up with a shell that loads instantly but individual routes that stutter on first visit. An app that precaches too much ends up with a bloated install step that delays the service worker's activation, which paradoxically makes the update problem this whole article is about even more noticeable to users.
"The service worker bugs that reach production almost always trace back to a cache name that never changed across a deploy, not a logic error in the fetch handler." - Dennis Traina, founder of 137Foundry
Testing This Before It Reaches Users
The fastest way to catch a stale service worker bug before shipping is to deploy to a staging environment, load the app, deploy again without closing the tab, and confirm the update banner (or forced refresh, if that's your pattern) actually fires. Testing this only with a hard refresh or an incognito window hides the exact bug that shows up in production, because both of those bypass the normal service worker lifecycle.
It's also worth checking behavior when a CDN sits in front of your assets, since a Cloudflare-style edge cache can independently serve an old asset even after the service worker itself has correctly updated, layering one staleness problem on top of another.
A Reasonable Rollout Checklist
Before shipping any change to service worker caching behavior, it's worth running through a short checklist rather than trusting memory: confirm the cache name includes a version identifier, confirm the activate event deletes stale-named caches, confirm the update prompt (or forced refresh) actually fires in a staging deploy, and confirm behavior across multiple tabs. Skipping any one of these is a plausible root cause the next time a "why is this user still seeing the old version" ticket shows up.
What to Do When You Inherit a Service Worker You Didn't Write
A lot of these bugs show up not when a team builds a service worker from scratch, but when someone inherits one written months or years earlier by an engineer who's since moved on. In that situation, resist the urge to guess at the intended behavior from reading the code alone. Instrument it first: log every install, activate, and cache deletion event to confirm what the worker is actually doing in production before changing anything, since the gap between intended and actual behavior is exactly where these staleness bugs tend to hide.
Once you have that visibility, the fixes described above, versioned cache names, an explicit update prompt, multi-tab testing, apply the same way they would to a service worker you wrote yourself.
It's tempting to just delete the inherited service worker entirely and start over rather than untangling it. Sometimes that's the right call, especially if nobody can explain why it was added in the first place. But confirm first whether any real offline capability depends on it, ideally by checking analytics for users on flaky or intermittent connections, before removing something that might be quietly load-bearing for a segment of users nobody in the room thinks about often.
Where This Fits in a Broader Caching Strategy
Service worker caching is one piece of a larger picture that includes HTTP headers and application-level caching, and treating it in isolation is how teams end up with three caching layers that don't agree with each other. A longer breakdown of designing a full client-side caching strategy covers how these layers should relate, including the versioning approach that keeps a service worker from becoming the layer nobody remembers to invalidate.
137Foundry's homepage has more on how we help teams debug exactly this class of deploy-related staleness bug.
Top comments (0)