Web Platform · Browser Storage

Cache API Match Semantics and Opaque Responses

How a Cache decides whether a stored entry answers a request, what Vary does to lookup, and why opaque cross-origin responses cost quota without being readable.

The Cache API looks like a dictionary from URL to response and is not one. It stores request-response pairs, decides matches by comparing request URLs and selected headers, and keeps no expiry information of its own. A Cache will happily return a response that the HTTP layer would consider stale, and will refuse to match a request whose query string differs by one character. The rules are short enough to state exactly, and knowing them removes most of the guesswork from cache lookups.

Entries are request-response pairs, matched by URL and Vary

caches.open(name) returns a Cache for a named cache within the origin’s CacheStorage; names are strings and a nonexistent name is created on demand. cache.put(request, response) adds a pair, cache.add(url) fetches and stores in one step, and cache.match(request) returns a promise for a stored response or undefined.

put accepts only GET requests. Passing a Request whose method is anything else throws a TypeError, and so does storing a response with status 206 (a partial response) or a response carrying Vary: *, since neither can be matched reliably later. A response’s body is a stream that can be consumed once: storing a response and then returning the same object to the caller fails on the second read, so the response must be cloned before one copy is stored and the other handed back.

const cache = await caches.open('assets-v3');
const response = await fetch('/app.js');
// The body is single-use; clone before storing one copy.
await cache.put('/app.js', response.clone());
return response;

A second put for a request that matches an existing entry replaces it. Because matching consults the stored response’s Vary header, two entries for the same URL can coexist when their Vary headers select different request headers; whether a put replaces or adds therefore depends on the headers of the request passed to it. Without Vary on the stored response, the URL alone decides, so put overwrites.

Matching: fragment out, query in

The match algorithm compares the stored request’s URL against the candidate’s. The fragment is ignored, because it is never sent over the network. Scheme, host, port, path, and query are all significant. /api/items?page=1 and /api/items?page=2 are different entries; /app.js and /app.js#main are the same. Trailing slashes are significant, and so is percent-encoding of the path: /a b and /a%20b normalise to the same URL under the URL parser, but /a%2Fb does not.

cache.match(request, { ignoreSearch: true }) drops the query string from the comparison, which is useful for caches keyed by path only but wrong for any endpoint where the query selects the resource. ignoreMethod: true allows a request with a method other than GET to match a stored GET entry. ignoreVary: true skips the header comparison described below. None of the options changes what is stored, only how lookup compares.

matchAll returns every matching entry in insertion order; it is the way to retrieve more than one variant for a URL. cache.keys() returns the stored requests without their bodies, which is how a cleanup pass finds old entries. caches.keys() lists cache names, and caches.delete(name) removes an entire cache in one operation, which is cheaper than deleting entries one at a time.

Vary rewrites the key

If a stored response carries a Vary header, matching is conditional on the request headers named there. RFC 9111 defines Vary as the list of request header fields that influenced the response; the Cache API applies the same idea at lookup. A stored response with Vary: Accept-Encoding only matches a request whose Accept-Encoding value is the same as that of the request used to store it. Change the header and the entry stops matching, even though the URL is identical.

Vary: * means the response can never be reused, and the Cache API refuses to store such a response at all. Header names are compared case-insensitively, and absent headers count as a value: a response stored from a request with no Accept-Language will not match one that sends it.

The practical effect is that a cache which stores responses fetched with different request headers can silently accumulate entries that will never be found again. Storing with a normalised request — the URL, with only the headers the application actually varies on — keeps matching predictable. ignoreVary: true is the blunt alternative, and it is correct only when the application knows there is one variant per URL.

Opaque responses are stored, unreadable, and padded

A fetch issued with mode: 'no-cors' to another origin yields an opaque response. Its type is "opaque", its status reads 0, its headers are empty, and its body cannot be read by script, but it can be stored in a Cache and returned later by a service worker, where the browser will still deliver the bytes to the page it belongs to.

Two consequences follow. First, an opaque response cannot be validated. response.ok is false because the status is 0, so code that checks ok before storing discards exactly the responses it intended to cache. Second, opaque responses are padded in quota accounting. If usage were charged by actual byte length, a cross-origin page could measure the size of a resource it is not allowed to inspect, so browsers add padding before charging the entry; in Chromium the minimum charge for a single opaque response is on the order of 7 megabytes, so a few-kilobyte image can consume a large slice of the origin’s budget.

That padding interacts badly with the per-origin quota: a cache of third-party assets can consume the entire allowance while holding very little data, and the failure appears as a QuotaExceededError on an unrelated database write. Caching same-origin responses, or cross-origin responses fetched with CORS so that they arrive as basic or cors responses with real status and headers, keeps accounting proportional to content.

// A CORS-enabled fetch yields a readable response with a real status.
const res = await fetch('https://cdn.example/lib.js', { mode: 'cors' });
if (res.ok) {
  await (await caches.open('vendor')).put('https://cdn.example/lib.js', res);
}

Batch inserts are all-or-nothing

cache.addAll(urls) fetches every URL and then stores the results as one batch. The fetch phase completes for every URL before any put is issued, so a failure — a network error, a non-ok status, a 206, or Vary: * — rejects the promise with nothing written, and the cache never holds half a set of files. That property is what makes it usable for an application shell: either the whole set of scripts, styles, and markup is present, or none of it is, and a later version of the code never sees a mixture of generations.

The guarantee covers the stored set, not the network: fetches happen concurrently, each with the credentials and mode of a normal fetch, and the batch is applied only after all of them have resolved. add(request) for a single entry has the same rule for its single item, rejecting rather than storing a non-ok response. Using explicit fetch plus put gives finer control and no atomicity, which is the right choice when some assets are optional; addAll is for the case where a partial cache is worse than an empty one.

Expiry is not part of the model

The Cache API stores no timestamps of its own beyond whatever the response headers carry, and match never consults Cache-Control, Expires, or Age. An entry stays until it is replaced, deleted with cache.delete, removed by caches.delete, cleared with the origin’s site data, or evicted under storage pressure. Deleting a cache by name is the reliable way to invalidate a whole generation of assets, and naming caches with a version component makes that a single operation.

Any freshness policy has to be implemented above the API: read the Date or a custom version header from the stored response and decide whether to refetch. The HTTP cache maintained by the network stack is a separate store with its own semantics, described by RFC 9111, and it is consulted when the service worker’s fetch handler calls fetch(). The two caches can disagree, which is why a response served from storage can look newer than one served from the network on the same page load.

Finally, the Cache API is available only in secure contexts and only in windows and workers that have access to caches. It is not available in a document whose origin is opaque, and entries are filed under the storage key, so a cache written in a third-party frame under partitioning exists only for that partition. The partition key article gives the reason; the consequence here is that pre-caching in an embedded context warms a bucket the top-level page does not read.

References

  1. Service Workers — W3C
  2. RFC 9111: HTTP Caching — IETF
  3. MDN — Cache — MDN Web Docs
  4. Chrome for Developers — Caching resources during runtime — Google