CORE JSC

International Technology Partnership

Web Development & SEO

Fixing Service Workers That Keep Serving Stale Content After a Deployment

A deploy ships, the server has the new files, and users still see yesterday's version — sometimes for days, until they clear site data by hand. The deploy isn't broken; the service worker is doing exactly what it was told to do: serve from cache first, and never really check for anything new.

Core JSC Team·August 2, 2026
Service WorkerPWACachingWeb DevelopmentDeployment

The Problem

A deployment goes out, the CDN and origin server are both serving the new build, and a manual check in an incognito window confirms the update is live — but real users, especially ones who visited the site recently or installed it as a PWA, keep seeing an old version. A normal reload doesn't fix it. Sometimes only a hard refresh works; sometimes nothing short of manually clearing site data in browser settings does. Support ends up telling users to "try clearing your cache," which isn't really an explanation, just a workaround.

Why It Happens

The service worker script itself gets cached by the browser's normal HTTP cache

Browsers periodically re-check a registered service worker file for changes, but if sw.js is served with a long Cache-Control header — common when it's served from the same static asset pipeline as everything else — the browser may keep using a cached copy of the service worker script itself for a long time, meaning it never even sees the new service worker code that would otherwise trigger an update.

A new service worker installs but never takes control

By default, a newly installed service worker sits in a "waiting" state until every open tab running the old service worker has been closed — a deliberate browser safety mechanism so an in-progress session doesn't have its logic swapped out mid-use. Without explicit code telling the new worker to activate immediately, users who keep a tab open (or a PWA that's rarely fully closed) can be stuck on the old worker indefinitely.

A cache-first strategy applied to the app shell itself, not just static assets

Caching hashed, immutable JS/CSS bundles aggressively is correct and desirable — their filenames change on every build, so a cache-first strategy for them is free performance with no staleness risk. The mistake is applying that same cache-first strategy to index.html or other unhashed entry points: once that's cached, the service worker will keep serving the old HTML — which still references the old, hashed asset filenames — indefinitely, regardless of what's actually deployed.

The Fix

1. Make sure the service worker file itself is never long-cached

# nginx example
location = /sw.js {
  add_header Cache-Control "no-cache";
}

This ensures the browser always revalidates the service worker script with the server on each check, so a new deployment's service worker code is actually seen instead of being served from a stale local copy.

2. Call skipWaiting() and clients.claim() so updates take effect immediately

// sw.js
self.addEventListener("install", () => {
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(self.clients.claim());
});

skipWaiting() tells a newly installed worker not to wait for old tabs to close before activating; clients.claim() tells it to take control of any already-open pages immediately, rather than only affecting the next full navigation.

3. Use network-first (or stale-while-revalidate) for the HTML entry point, cache-first only for hashed assets

self.addEventListener("fetch", (event) => {
  const isHTMLRequest = event.request.mode === "navigate";
  if (isHTMLRequest) {
    event.respondWith(
      fetch(event.request).catch(() => caches.match(event.request))
    );
    return;
  }
  event.respondWith(
    caches.match(event.request).then((cached) => cached || fetch(event.request))
  );
});

The HTML entry point always tries the network first, falling back to a cached copy only if the network is unavailable — so a live deploy is picked up on the very next navigation. Hashed static assets stay cache-first, since their content genuinely never changes for a given filename.

4. Version cache names and delete stale caches on activation

const CACHE_NAME = "app-cache-v12"; // bump on every meaningful change

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
    )
  );
});

Without cleanup, every deployed version's cache lingers forever, silently growing storage usage and occasionally serving mismatched entries from a much older cache the newer code never intended to touch.

Why This Works

Each of these fixes closes one specific gap in the update lifecycle: keeping the service worker script itself uncached means updates are actually discoverable; skipWaiting/clients.claim means a discovered update takes effect without requiring every tab to be manually closed; network-first HTML means the entry point that decides which asset versions to load is never itself stale; and cache versioning means old, mismatched cache entries don't linger to serve inconsistent content later. None of it disables caching — it makes caching aggressive exactly where safe (immutable, hashed assets) and conservative exactly where staleness actually causes visible bugs (the HTML shell).

Conclusion

A service worker serving stale content after a deployment is doing precisely what a naive cache-first configuration told it to do — the fix isn't to remove caching, but to make the update lifecycle explicit: an uncached service worker script, immediate activation via skipWaiting/clients.claim, a network-first strategy for the HTML entry point, and versioned cache cleanup so old deployments don't linger indefinitely in a user's browser.