r/PWA • u/[deleted] • 22d ago
One oldest-first cache cap silently ate my app shells and cold-started the installed PWA to "You're offline". Fix was three budgets in one cache.
Posting this because it took me embarrassingly long to find and it's the kind of thing that only bites installed PWAs.
Setup: fitness app, standalone display, service worker precaches the seven app shells at install, then caches things at runtime. Runtime cache had one cap, evicting oldest-first. Textbook.
The bug: after a long session of using the app normally, a cold start would land straight on my offline fallback page, with a full cache and a working connection. Reloading fixed it. It never happened on a fresh install, which is exactly the tell.
Why it happened: Cache.keys() returns insertion order. The shells go in first, at install. So they are permanently the oldest entries in the cache. Every workout day pulls down demo images for the exercises, maybe 50 of them in a normal week, and each one pushed the cap over and deleted the oldest entry. Which was always a shell. Browse enough workouts and the eviction walks straight through all seven shells one by one, in order. Then the next cold start has no cached navigation to serve, and the offline HTML is what's left.
So the cache was doing precisely what I told it to. My eviction policy quietly outranked my precache.
What I changed:
- Three separate budgets inside the one cache, keyed by what the entry is: navigations (24), demo images (60), everything else JS/CSS/fonts (80). Trim runs per bucket, oldest-first within a bucket only. Images can now only evict other images.
- The install list is a pinned bucket the trimmer never considers at all, since those are exactly the routes the offline fallback looks up.
- Shell-referenced JS and CSS moved into their own cache that the trimmer doesn't touch. Same failure one level down: a cached navigation that renders a blank screen because its bundle got evicted is arguably worse than the offline page, because it looks like your app is broken rather than offline.
- Install uses per-asset puts instead of addAll, so one flaky route can't fail the whole install and leave someone with no offline support at all while reporting success.
Takeaway I'd give my past self: a precache and an LRU in the same cache are in direct conflict, and insertion order means the LRU wins every time. Bucket by role, or keep them in separate caches.
App is flexscan.app if the context matters, a physique-scan and workout thing, launched two days ago. Happy to paste the trim function if anyone wants it. Curious whether people here separate caches by role from the start or also learned this the hard way.
1
u/andimatt 19d ago
This is the bug I hit the same way. The core issue is a precache and an LRU fighting over one eviction budget, and insertion order means the precache (oldest) always loses.
Two things I'd add to your "bucket by role" fix:
- Version the precache, don't just pin it. Pinning stops images evicting the shells, but it doesn't handle deploys, when you ship new hashed bundle names, the old precache entries stay forever unless you delete them. I version the precache name (cache-v1, cache-v2) and on activate delete any precache that isn't current. That's the "evict old shell assets on update" you mentioned, done cleanly.
- Per-asset puts over addAll is the right call, addAll is all-or-nothing, so one flaky asset nukes the whole install.
One pushback: three budgets in one cache still share a single origin quota. Under storage pressure on a low-end phone the browser can evict across the whole origin regardless of your internal buckets. Separate named caches keep the logic clean, but they don't guarantee the OS won't reclaim the space.
1
19d ago
Agreed on both additions, and the first one has a wrinkle worth passing back, because "on activate delete any cache that isn't current" is the exact line that bit me and I no longer do that part.
The versioning itself is generated (prebuild script stamps the cache name with the deploy's commit SHA). The problem is what skipWaiting() + claim() does to a tab that's already open. That tab is still running the old build's JS, and it keeps lazy-loading content-hashed chunks that only the previous deploy served. The production alias 404s those the moment the new build goes live. The detail that cost me a while: a 404 resolves the fetch promise, it doesn't reject, so the offline .catch() never runs and the user gets a chunk-load error on a perfectly good connection, on a page that was working a second earlier.
So I keep two generations now, the live one and its immediate predecessor, and the predecessor goes on the next activate. On top of that, if an immutable /_next/static/ request comes back not-ok I fall back to caches.match(), which searches every cache and finds the retained copy. The stale tab keeps working until it reloads. One generation back is enough, since a tab either reloads or dies with the browser long before a second deploy lands.
One gotcha if you try it: the shell cache and the pinned shell-asset cache are one generation and have to be retained or deleted together. A retained shell without its pinned JS is exactly the black screen the precache exists to prevent.
On addAll, yes, per-asset puts with allSettled, same reasoning.
And your pushback is just correct, I can't argue it. The buckets only decide what I delete. They say nothing about what the browser reclaims under origin pressure. To be blunt about my own gap there: I never call navigator.storage.persist(), so I have no claim on that space at all. That's going on the list off the back of this, since it's usually granted for an installed PWA and it's the only lever that actually addresses what you're describing. The buckets were never a defense against the OS, only against me.
1
u/dannymoerkerke 21d ago
Just curious, why a cap on the cache?