Web Platform · Progressive Web
Cache Storage Keys, Vary, and Cache Versioning
How the Cache API compares a request against stored entries, why HTTP freshness headers are inert inside it, and how cache names and activate-time deletion keep a deployment consistent.
caches is a per-origin map from cache names to Cache objects, and each Cache is an ordered map from Request keys to Response values. The ordering is insertion order, and the lookup is a linear scan from the most recently inserted entry backwards. That much is simple. The subtlety lies in the comparison function: what counts as the same request, and what the stored response’s own headers mean once it is inside the cache.
The match algorithm
cache.match(request, options) returns a promise for the first stored response whose key matches. Matching starts from the request’s URL with the fragment removed — /app.js#v2 and /app.js are the same key — and then applies a sequence of narrowing tests controlled by the options object.
The method must match unless ignoreMethod is true. A cache entry stored under a GET does not answer a POST by default. In practice nearly every stored entry is a GET, because cache.put() rejects requests whose method is not GET; the exception is that a handler can construct a Request with a different method and pass it to match() with ignoreMethod: true, a pattern sometimes used to answer a HEAD from a cached GET.
The query string is part of the key unless ignoreSearch is true. That flag is a blunt instrument: it makes /search?q=a and /search?q=b identical keys, so a single stored response can answer both. It is useful for precached shells whose query parameters are routing noise and dangerous for anything whose content depends on the query.
The Vary header on the stored response determines a further comparison against the candidate’s headers. A response stored with Vary: Accept-Encoding only matches a request whose Accept-Encoding header matches the stored request’s value. Vary: * never matches. ignoreVary: true disables the check entirely, which is the escape hatch for a cache whose entries were stored from requests with headers the handler cannot reproduce, such as a request whose Accept header was rewritten by an extension.
Two consequences follow from this design. First, key equality is not byte equality of the URL plus everything else; two requests can differ in headers, credentials mode, and cache mode and still match if Vary does not cover the difference. Second, the scan is backwards, so when more than one stored entry matches the same lookup — the same URL stored with different Vary-covered headers, for instance — the most recently added entry wins. put() itself removes every entry its request matches before appending the new one, so re-storing a hot key replaces it rather than accumulating copies. Growth comes from keys the handler keeps inventing, such as cache-busting query strings, not from re-storing one key.
Freshness headers do nothing
The HTTP cache and the Cache API share the name “cache” and almost nothing else. The HTTP cache consults Cache-Control, Expires, ETag, and Last-Modified, revalidates with conditional requests, and evicts by its own policy. The Cache API stores a Response and returns it byte for byte whenever the key matches. A stored response carrying max-age=60 is not expired sixty seconds later; a stored response carrying no-store is returned just the same if something put it there. No revalidation happens, no conditional request is issued, and no freshness calculation is performed.
const cache = await caches.open('shell-v3');
// Stores the response as received, headers and all. The headers are inert
// for future lookups; only Vary participates in matching.
await cache.addAll(['/app.css', '/app.js', '/offline.html']);
const hit = await cache.match('/app.css', { ignoreSearch: true });
The freshness metadata is still readable, and a handler that wants HTTP-like behaviour has to implement it: parse Date and Cache-Control, keep a timestamp alongside the entry, and decide whether to revalidate with If-None-Match. That is a reimplementation of the HTTP cache’s core, which is why strategies usually choose between explicit policies — cache-first with a version bump on deploy, or network-first with a timeout — rather than emulating freshness. stale-while-revalidate exists as an HTTP directive precisely because the browser’s own cache can implement it without script involvement; inside the Cache API, the equivalent is a handler that returns the stored response and starts a background fetch().then(r => cache.put(...)) guarded by event.waitUntil.
One header does survive into semantics: Content-Type decides how a cached response is interpreted by the consumer, and a cached entry returned for a navigation is parsed as HTML only if its type says so. Storing an API response under a document URL, or vice versa, produces a document that renders as text.
Versioning by cache name
Because entries are immutable until deleted and there is no per-entry expiry, the practical versioning unit is the whole cache. A deployment names its cache after a build identifier, opens it during install, precaches the asset list, and deletes the previous generation during activate.
const CACHE = 'shell-2025-06-a';
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE);
await cache.addAll(['/', '/app.css', '/app.js', '/offline.html']);
await self.skipWaiting();
})());
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
for (const key of await caches.keys()) {
// Delete only this worker's older generations, not other caches on the origin.
if (key.startsWith('shell-') && key !== CACHE) await caches.delete(key);
}
await self.clients.claim();
})());
});
The delete loop must be scoped to the caches this code owns. caches.keys() returns every cache created by the origin, including ones created by another worker with a different scope or by a library on a different path, and deleting them breaks those consumers. A shared prefix in the names — shell-* — is enough to distinguish ownership.
addAll gives the install step an all-or-nothing property. The specification fetches every request first and only inserts once all fetches have succeeded, so a single 404 or network error rejects the install and leaves no partially populated cache behind. That is the behaviour wanted for an app shell: an install that cached a stylesheet but not its fonts would produce a page that renders, then fails to load. Where partial caching is genuinely acceptable, individual cache.put() calls in a loop, each awaited, express that intent more clearly and let the install continue past a single failure.
The cost of versioning by name is the moment between caches being written and the old ones being deleted. During that window two generations of every asset exist on disk, so the storage peak is roughly twice the deployed asset size. For an app shell measured in hundreds of kilobytes this is irrelevant; for a precached media library it is the difference between fitting in quota and not, and the deletion step then has to be moved earlier or the cache split so that only the small shell is versioned.
Opaque responses and what they cost
A no-cors fetch of a cross-origin resource produces an opaque response: status 0, empty headers, and a body unreadable from script. cache.put() accepts it, and a later match() returns it to the page, where the browser can still use it as an image or a font source. cache.add() and cache.addAll() reject it, because both require an ok status and an opaque response has none; cross-origin assets therefore enter the cache through an explicit fetch(url, { mode: 'no-cors' }) followed by cache.put().
The storage accounting for such entries is not the byte size of the resource. Browsers pessimistically pad opaque responses, because a worker cannot tell how large the body is and a shared-cache timing attack could otherwise infer it. The padding is coarse, on the order of megabytes per entry in Chromium’s implementation, which makes caching many small cross-origin resources under no-cors substantially more expensive than their size suggests. Caching them under CORS, with the server sending Access-Control-Allow-Origin, avoids both the padding and the unreadable body.
When the cache is wrong
Three failure patterns recur. A cache name that is not tied to the deployment — 'shell' rather than 'shell-2025-06-a' — means the precache step overwrites entries in place, and a client that installs mid-deploy can end up with a mixture of old and new files under one name. ignoreSearch: true on a cache that also stores API responses makes /items?page=2 answer with /items?page=1’s body. And a handler that stores responses without checking response.ok writes error pages and redirects into the cache permanently, because nothing evicts them; the check belongs at the single point where put() is called.
How those stored entries are then served — cache-first, network-first, stale-while-revalidate — is a routing decision rather than a storage one, and the navigation paths that need it are covered in Offline Navigation, App Shell, and Fallback Routing. The storage question underneath is separate: how much of the origin’s quota these caches may occupy before the browser starts evicting them, which is the subject of Storage Quota, Persistence, and Eviction.
References
- Service Workers Nightly — Cache objects — W3C Web Applications Working Group
- RFC 9111: HTTP Caching — IETF
- Cache — MDN Web Docs