Every deploy of your app creates a new named cache with the browser's Cache API, and users' storage grows without bound. How should named caches be versioned and cleaned up, and what order must the operations happen in?
answer
- nothing removes a named cache for you
- enumerate before you delete
- keep a prefix you own
- new generation ready first
- delete resolves true or false
basics
~20 sNothing deletes a named cache automatically. Enumerate with caches.keys() and remove the ones you no longer recognise with caches.delete(name) — after the new generation is fully populated, never before, so a failed deploy still has something to serve.
solid answer
~50 s`CacheStorage` is a flat, permanent map of names to caches: opening `static-v4` does not touch `static-v3`, and no browser mechanism ever removes an old name. Cleanup is code you write. The standard shape is to derive the current names from one version constant, populate the new caches completely, and only then call `caches.keys()` and `caches.delete(name)` for every name that is not in the current set. Ordering matters in both directions: deleting first leaves a window where a request can find nothing cached, and deleting a cache the running page is still reading from makes in-flight `match()` calls miss. Prefer a small, closed set of names you can recognise by prefix, so a stray cache from a third-party library is not swept away. Note that `caches.delete()` resolves `true` when a cache existed and `false` otherwise — a useful assertion in deploy telemetry.
code
javascript · 16 linesconst VERSION = 'v4';
const OWNED = /^(static|pages)-/;
const CURRENT = new Set([`static-${VERSION}`, `pages-${VERSION}`]);
async function deploy() {
// 1. populate the new generation first - addAll is all-or-nothing
const cache = await caches.open(`static-${VERSION}`);
await cache.addAll(['/', '/app.js', '/app.css']);
// 2. only now remove generations we own and no longer recognise
const names = await caches.keys();
const stale = names.filter((n) => OWNED.test(n) && !CURRENT.has(n));
const removed = await Promise.all(stale.map((n) => caches.delete(n)));
console.log('removed:', stale, removed); // delete resolves true/false
}
deploy();go deeper
Know that caches.keys() lists the origin's cache names and caches.delete(name) removes one, and that neither happens by itself when you deploy a new version.
Explain the versioned-name pattern: derive names from one constant, populate the new generation fully, then delete names you no longer recognise within your own prefix.
Reason about ordering and blast radius — the gap left by deleting first, misses caused by deleting a cache a live page still reads, and why unrecognised names must be left alone.
Own the storage budget across releases: how many generations may coexist, how a failed deploy rolls back to the previous cache, and which content is versioned with the build versus preserved across it.
## CacheStorage is a permanent, flat namespace `caches` maps arbitrary strings to `Cache` objects. Three facts follow, and together they explain the unbounded growth: 1. `caches.open('static-v4')` creates the cache if it does not exist and returns it if it does. It never inspects, migrates or removes any other name. 2. There is no expiry, no LRU and no generational cleanup. A cache named `static-v1` created two years ago still exists, still holds every entry it was given, and still counts against the origin's storage. 3. The namespace is shared by everything running on the origin — your own code, an analytics SDK, a framework's build tooling. Names collide silently. So an app that bumps a version string each deploy accumulates one full copy of its assets per release until the browser evicts the entire origin under storage pressure — which is a blunt, user-visible event, not a cleanup strategy. ## The standard shape Derive every name from one constant, and treat "names I recognise" as the definition of what to keep: ```js const VERSION = 'v4'; const CURRENT = new Set([`static-${VERSION}`, `pages-${VERSION}`]); async function cleanUp() { const names = await caches.keys(); await Promise.all( names .filter((n) => n.startsWith('static-') || n.startsWith('pages-')) .filter((n) => !CURRENT.has(n)) .map((n) => caches.delete(n)) ); } ``` The prefix filter is not decoration. `caches.keys()` returns every cache on the origin, so a naive "delete anything not in CURRENT" wipes caches owned by other code on the same origin. Own a namespace and stay inside it. ## Ordering: populate, then delete The sequence must be **create and fully populate the new generation, then delete the old ones**. Reversing it opens a window where the old entries are gone and the new ones are not yet written; a user who loads the app in that window — or goes offline in it — finds nothing cached at all. Because `cache.addAll()` is all-or-nothing, it is a natural gate: if it rejects, you have not deleted anything, and the previous generation is still intact and serving. There is a second ordering hazard on the other side. Deleting a cache while something is still reading from it does not throw, but subsequent `match()` calls against that name resolve with `undefined`. If a page loaded against `static-v3` is still running when `static-v3` disappears, its lookups start missing and fall through to the network. That is survivable when online and not when offline, which is why cleanup is conventionally done at a point where the new generation has already taken over rather than immediately on script evaluation. ## What the return values tell you - `caches.keys()` resolves with the names in creation order — the cheapest audit of what is actually on a user's disk. - `caches.delete(name)` resolves with `true` if a cache with that name existed and was removed, `false` otherwise. Logging a `false` where you expected `true` catches a version-string typo that would otherwise leak a whole generation. - `caches.has(name)` exists but is rarely the right call; `keys()` gives you the full picture in one round trip and avoids a check-then-act race. ## Entry-level versus cache-level invalidation Two granularities are available and they suit different content. **Cache-level** (bump the name, delete the old one) is right for immutable build output. Every asset of one release lives or dies together, so you never serve a new HTML shell against an old JavaScript bundle — the classic skew bug. **Entry-level** (`cache.delete(request)`) is right for long-lived data caches that must survive deploys. Bumping the name here would discard, on every release, content the user may need offline. `cache.keys()` plus your own metadata — a timestamp stored alongside, or a parallel IndexedDB record — is how you implement age-based pruning, because the API itself records no insertion time. ## A note on naming Make the name carry the schema, not just the release: `static-v4` is fine when the layout of the entries never changes, but if you change *how* you key entries — adding a synthetic query, switching from full URLs to hashed names — the old generation is not merely stale, it is unreadable by the new code. Bumping a separate schema segment (`static-s2-v4`) makes that explicit and makes the cleanup filter obvious.
- Why is deleting old caches before populating the new one dangerous rather than merely untidy?It creates a window with no cached copy at all. A user who loads the app during it, or loses connectivity in it, gets nothing — and if the population step then fails, the app is left with no offline capability instead of the previous, working generation. Populate first and the failed path degrades to "still on the old version".
- Why filter cache names by prefix instead of deleting everything not in the current set?`caches.keys()` returns every cache on the origin, including ones created by third-party scripts, an SDK, or another app deployed under the same origin. Deleting unrecognised names destroys other people's data and produces bugs nobody can trace back to your deploy. Own a prefix and never touch anything outside it.
- When should you invalidate individual entries rather than bumping the whole cache name?When the cache holds user-facing data rather than build output — offline articles, drafts, downloaded media. Bumping the name would throw that away on every release. Use `cache.delete(request)` driven by your own metadata, since the API stores no insertion timestamp and offers no age-based pruning of its own.
- What does `caches.delete()` resolve with, and why is that worth checking?`true` if a cache with that name existed and was removed, `false` if there was nothing to delete. Logging an unexpected `false` during a deploy catches a mistyped or drifted version string, which is exactly the failure that silently leaves a whole generation of assets on every user's disk.
saying these in an interview costs you the question
- Assumes old named caches are cleaned up automatically
- Deletes the previous cache before the new one is populated
- Deletes every name returned by caches.keys() without filtering
- Thinks opening a new cache name migrates the old entries
- Expects the Cache API to prune entries by age