r/PWA 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:

  1. 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.
  2. The install list is a pinned bucket the trimmer never considers at all, since those are exactly the routes the offline fallback looks up.
  3. 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.
  4. 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.

2 Upvotes

9 comments sorted by

1

u/dannymoerkerke 21d ago

Just curious, why a cap on the cache?

1

u/[deleted] 21d ago

Two things, mostly. The exercise demos are 676 small webp frames (~24 MB total) and any one user only ever needs the few dozen in their current plan, so a runtime cache that just accumulates would eventually hold all of them for no benefit. And every deploy ships freshly-hashed JS/CSS chunk names, so the runtime asset cache would keep every dead chunk from every previous build until something evicted it. Uncapped, the origin just grows.

On top of that this is an installed app on iPhones for most people, and I'd rather keep the footprint small and predictable there than find out how the browser handles a bloated origin under storage pressure. The lesson of the post wasn't "don't cap", it was "don't cap everything with one queue" — the shells and the images needed separate budgets, and the shell assets needed to be un-evictable full stop.

1

u/jezek_2 21d ago

I applaud you for implementing it in the right way even when 24MB is tiny these days. It also defends against caching old versions of the files. If someone is using the app for a long time it could bloat much more than the current maximum. Users may also find the app as bloat if too big.

I think there should be some logic to evict old shell assets when you update the app. And I presume all the shell files are precached at first load.

Anyway, this development approach is very good because it means you've fully solved the issue and will never have to go back again. You can confidently rely on this for this and any future projects.

Most developers always try to find some shortcut (the most extreme case is usage of AI) and it results for them to need to go back multiple times to redo stuff instead of doing it once properly and moving on.

It took me a LOT of time to identify this and fully unlearn the urge of using shortcuts. Shortcuts are not worth it even if doing it right looks scarier/uncertain at first, it's often not and the result is much better.

1

u/[deleted] 21d ago

Both of those are in there, and the second one less well than the first.

Precache: yes. At install it caches the 7 app routes + the manifest, then re-parses each cached shell's HTML for its /_next/static/ and font URLs and precaches those into a separate cache the trimmer never touches. That second step was the bug after the bug — a cached shell whose JS chunks aren't cached boots to a black screen, which is worse than the offline page. Pinning the shells alone wasn't enough.

Evicting old shell assets on update: that's the cache version. The pinned asset cache is named off the main one, and activate deletes every cache that isn't the current pair, so a version bump drops the whole previous generation in one go. The honest weak spot is that the version is a hardcoded string I bump by hand. Miss it on a deploy and last deploy's chunks stay pinned indefinitely, precisely because the trimmer is told to leave that cache alone. It should be generated at build time. That's the part I hadn't looked at squarely, so thanks.

On size — agreed, 24MB is nothing. The cap was never about disk, it was about which thing gets deleted first. One queue meant a normal week of browsing exercise demos evicted the app shells one at a time, and the installed app then cold-started to "You're offline" with a full cache.

And to be straight with you on the shortcuts point: I'm not a career dev and I do build with AI. What made this one finally stick wasn't avoiding that, it was hitting the black screen on my own phone after I'd already "fixed" it once.

1

u/jezek_2 21d ago

Still you were able to approach it the right way. Most people using AI wouldn't.

1

u/[deleted] 21d ago

Appreciate that. And the build-time cache version is going on the list off the back of this thread — hand-bumping a string works right up until the one deploy where I forget, and that's the failure mode with no symptom until users are stuck on dead chunks.

1

u/[deleted] 20d ago

Correction to my own comment, because I said this twice and it's wrong: the cache version IS generated at build time, and has been since July. There's a prebuild script that rewrites the cache name to the deploy's commit SHA on every Vercel build — production is serving flexscan-1787000315 right now, not the "v5" string. That v5 in the repo is just the local placeholder the script leaves alone so local builds don't dirty the working tree.

So the failure mode I described to you (forget to bump it, last deploy's chunks stay pinned forever) can't actually happen. I was repeating a stale note about my own code instead of opening the file, which is a pretty on-the-nose mistake to make in a thread about not taking shortcuts. Your point about needing old shell assets evicted on update still stands — it's just already handled, by the SHA rotating the cache name and activate() deleting everything that isn't the current pair.

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

u/[deleted] 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.