Cache-first was the bug
Any state a user can enter but cannot leave is a defect, however good the reason for entering it was.
We shipped a service worker with a cache-first strategy and a fixed cache name. It is the canonical example from every tutorial, it makes the application load instantly, and it is correct for immutable, content-addressed assets.
Our stylesheet and application bundle were neither immutable nor content-addressed. So a user who installed the application during a week of active development could be pinned, permanently, to that week's CSS — while the source had been fixed, deployed and verified days earlier. Reloading did not help. The reload was served from the cache too.
The property that was missing
The rule we now apply is not about caching. It is about reachability:
Every state a client can enter must have an edge back out that the user can traverse without instructions from support.
Cache-first with a fixed key creates an absorbing state. Once a client is in it, no action available to the user leaves it — not reopening the app, not reconnecting, not waiting. Only an action by the developer, taken before the client got stuck, could have prevented it. That is a hard failure mode to discover in testing, because the developer's own machine is never in the absorbing state.
The change
For our own assets, the strategy is now network-first with the cache as an offline fallback. The user always gets current code when they have connectivity, and still gets a working application when they do not. We pay a few hundred milliseconds on a warm connection. It buys the guarantee that a deployed fix reaches every user.
// Own assets: network-first. The cache is a fallback for offline,
// not the source of truth — a shipped fix must always be able to
// reach a client that already installed the app.
self.addEventListener("fetch", (event) => {
if (!isOwnAsset(event.request)) return;
event.respondWith(
fetch(event.request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE).then((c) => c.put(event.request, copy));
return response;
})
.catch(() => caches.match(event.request)),
);
});Content-hashed assets can stay cache-first: a new build produces a new URL, so the absorbing state does not exist. The strategy should follow the mutability of the resource, and the tutorial's example happened to assume the immutable case without saying so.
Where else absorbing states hide
- A persisted feature flag with no server-side override path.
- Migrated local storage that a newer schema silently refuses to read.
- A cached authentication token that is expired but never revalidated.
- A permission denied permanently, with no in-app route to the operating system settings that could restore it.
- An onboarding step marked complete on a client whose data never actually saved.
The review question is short enough to ask on every change that writes to client-side storage: if this value ends up wrong on a user's device, what sequence of actions available to that user makes it right again? If the honest answer is "reinstall", the design is not finished.