The Stale-Content Trap: Why PWA Users Saw Old Data Even in Incognito

A deploy went green and production curled fresh, yet a tester saw stale content in incognito. Three cache layers each held a copy — CloudFront edge, browser HTTP cache, and the Service Worker precache — and only no-cache on the whole app shell fixed it.

I shipped a wording change to CareReady, our offline-first PWA. The deploy went green. I curled production and saw the new text. Then the tester — on a completely different device — said they still saw the old wording. In incognito. That last word is what made it interesting: incognito has no persistent cache, so how could it be stale?

It turned out three separate caches were each holding a copy, and I’d only fixed one of them.

The setup

CareReady is a no-build, no-hash PWA: index.html, app.js, data.json, sw.js. It’s served from S3 behind CloudFront, with a Service Worker doing cache-first + background revalidate. On deploy we already treated the two “obvious” files specially:

  • sw.jsCache-Control: no-cache
  • index.htmlCache-Control: no-cache
  • everything else (including app.js and data.json) → public, max-age=3600

That max-age=3600 on the app’s own logic and data is the whole bug. Here’s how it bites, in three layers.

Layer 1: the CloudFront edge

data.json had max-age=3600, so each CloudFront edge cached it for an hour. We did run an invalidation on every deploy, but invalidations propagate, and my curl from one network hit a different (already-refreshed) edge than the tester’s phone. Same URL, two edges, two answers. This alone explains “fresh for me, stale for them” — including in incognito, because incognito still fetches from the same shared CDN edge.

Layer 2: the browser HTTP cache

Once any page load fetched data.json, the browser kept it for an hour under max-age. A CloudFront invalidation does nothing here — it clears the edge, not the copy already sitting in someone’s browser. A fresh incognito session dodges this on the first fetch, but reuse the window and you’re back to the cached copy.

Layer 3: the Service Worker precache (the sneaky one)

This is the layer that keeps biting people who think “the SW is no-cache, so updates are instant.” The SW script is re-fetched (it’s no-cache), so a new version installs. On install it runs:

caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS));

But cache.addAll fetches those URLs through the browser’s HTTP cache. So the brand-new Service Worker dutifully precaches the stale app.js and data.json that Layer 2 handed it. You bumped CACHE_NAME, the SW updated, and it still serves old content — because the fetch that filled the new cache read a one-hour-old copy.

That’s the trap: an up-to-date Service Worker can precache out-of-date assets, and no amount of CACHE_NAME bumping fixes it if the underlying files are cacheable.

The fix

Treat the entire app shell the way you already treat the HTML and the SW:

# icons rarely change → long cache
aws s3 sync dist/icons/ s3://bucket/ready/icons/ --cache-control "public,max-age=86400"
# html / js / json / manifest / sw → no-cache = always revalidated
aws s3 sync dist/ s3://bucket/ready/ --exclude "icons/*" --cache-control "no-cache"

no-cache doesn’t mean “never cache” — it means “cache, but revalidate every time” (a conditional request that returns 304 Not Modified when nothing changed). The bytes still get reused; you just never serve a stale copy blind. With that in place:

  • the edge revalidates with the origin,
  • the browser revalidates before using its copy,
  • and cache.addAll on SW install pulls the current files.

For immediate relief on the already-poisoned edges, I also ran a manual create-invalidation — but the real fix is the header, so I never have to think about invalidation timing again.

The rule I should have started with

For a PWA whose asset URLs aren’t content-hashed:

  • App shell (HTML, JS, JSON, the SW itself): no-cache. These change and their URLs don’t, so they must always revalidate.
  • Only genuinely static, rarely-changing assets (icons, fonts) get a long max-age — and ideally those are content-hashed if they change.

If your asset URL can point at two different payloads over its lifetime, it should not carry a long max-age. The Service Worker will not save you; it will faithfully cache whatever the HTTP layer hands it.

Takeaway: bumping the Service Worker cache name updates the cache key, not the bytes. If the shell is max-age-cacheable, your fresh SW will precache stale files — even for someone in incognito hitting a lagging edge.

This is part of building CareReady, a released web app at VEAI LAB. My broader technical-PM profile is at hire-veai.com.

Thanks for reading — built with care, for caregivers.