skip to content

The Cache API

You will learn the request/response store that service workers use, and how it differs from the HTTP cache you do not control. Interviewers pair it with offline strategies and the opaque-response size surprise.

on this pageshow

questions

5

You stored a response with the browser's Cache API and are sure the URL is right, yet `cache.match(request)` resolves with `undefined`. What are the usual causes, and which `match` options address them?

level: middleimportance: must knowfreq 58%

answer

  1. the key is a Request, not a URL string
  2. query string counts
  3. one response header is still honoured
  4. method is compared too
  5. open the right named cache

basics

~20 s

Cache API lookups key on the full URL including the query string, honour the stored response's Vary header, and only match GET requests. The ignoreSearch, ignoreVary and ignoreMethod options relax each rule; a wrong cache name is the fourth cause.

solid answer

~40 s

Four things cause a surprising miss. First, the query string is part of the key — `/items` and `/items?page=2` are different entries, and `{ ignoreSearch: true }` makes the lookup compare only origin and path. Second, if the stored response carries a `Vary` header, the browser re-checks those request headers, so a different `Accept` or `Accept-Language` misses; `{ ignoreVary: true }` skips that check. Third, only `GET` requests match by default, so looking up a `POST` or `HEAD` request finds nothing unless you pass `{ ignoreMethod: true }`. Fourth, you may be looking in the wrong `Cache` — `cache.match()` searches one named cache, while `caches.match()` searches all of them for the origin, optionally narrowed with `{ cacheName }`. When debugging, `cache.keys()` shows the actual stored `Request` objects, which usually reveals the mismatch immediately.

code

javascript · 16 lines
javascript
async function why() {
  const cache = await caches.open('lookup-demo');
  await cache.put('/api/items', new Response('[]', {
    headers: { 'Content-Type': 'application/json' }
  }));

  console.log(await cache.match('/api/items?page=2'));
  // undefined - the query string is part of the key

  console.log(await cache.match('/api/items?page=2', { ignoreSearch: true }));
  // Response - search ignored on both sides

  console.log((await cache.keys()).map((r) => `${r.method} ${r.url}`));
  // shows exactly what is stored
}
why();

go deeper

for a junior

Remember that the cache key is the full URL including any query string, and that cache.keys() will show you exactly what was stored when a lookup surprises you.

for a middle

Explain each option — ignoreSearch, ignoreVary, ignoreMethod, cacheName — and say which mismatch it fixes, including why Vary is the one response header matching still honours.

for a senior

Show the risk side: relaxing matching creates false hits on paginated or content-negotiated endpoints, so argue for fixing the key rather than loosening the comparison.

for a principal

Own the key design across the app: what belongs in a URL versus a header, how versioned asset URLs interact with lookups, and how invalidation stays tractable as the number of entries grows.

## What a lookup actually compares `cache.match(request, options)` runs a matching algorithm, not a string equality test on a URL. Understanding the four inputs to that algorithm explains essentially every mysterious miss. The key stored in a `Cache` is a whole `Request`, so a lookup compares the **serialised URL** (scheme, host, port, path *and* query string, with the fragment ignored), the **method**, and — if the stored *response* says so — a set of **request headers**. ## Cause 1: the query string is part of the key This is by far the most common surprise. `/api/items` and `/api/items?page=2` are two different keys, as are `?a=1&b=2` and `?b=2&a=1`, because the comparison is on the serialised URL, not on a parsed parameter set. A cache-busting suffix such as `/app.js?v=1749` likewise makes the entry unreachable from a request for `/app.js`. ```js await cache.put('/api/items', res); await cache.match('/api/items?page=2'); // undefined await cache.match('/api/items?page=2', { ignoreSearch: true }); // hit ``` `ignoreSearch: true` strips the query from both sides before comparing. Be deliberate about it: if the query genuinely selects different content, ignoring it returns the wrong body. ## Cause 2: `Vary` is enforced during lookup The Cache API ignores freshness headers entirely, but it *does* consult one header — `Vary` — and it does so on the **stored response**. If the stored response carries `Vary: Accept-Encoding, Accept-Language`, then a candidate entry only matches when the incoming request's values for those header names equal the values on the request that was originally stored. That produces misses that look impossible, because nothing about the URL changed: - A response stored during a fetch from a service worker had no `Accept-Language`, while the page's navigation request has one. - An API returns `Vary: Accept`, and one call asked for `application/json` while another sent the default. - `Vary: *` never matches anything, by definition. `{ ignoreVary: true }` disables the check. Use it when you know the variation does not affect the bytes you stored; otherwise fix the key instead — for example by storing under a synthetic URL that encodes the variant. ## Cause 3: only GET matches A `Cache` key must be a GET request with an http or https URL — `put()` rejects anything else. Matching enforces the method too, so passing a `Request` built with `method: 'POST'` or `'HEAD'` finds nothing. `{ ignoreMethod: true }` makes the algorithm skip the method comparison, which is mainly useful for looking up a HEAD request against a stored GET entry. ## Cause 4: the wrong cache `cache.match()` searches exactly one named `Cache`. If a deploy bumped the name from `static-v3` to `static-v4`, a handler still opening `static-v3` will read from an empty or stale generation. `caches.match(request)` searches every cache of the origin in creation order and returns the first hit, and accepts `{ cacheName: 'static-v4' }` to narrow it. All the other options pass through: ```js const hit = await caches.match(request, { ignoreSearch: true, cacheName: 'static-v4' }); ``` ## Debugging technique When a lookup misbehaves, stop guessing and print the keys: ```js const cache = await caches.open('static-v4'); console.log((await cache.keys()).map((r) => `${r.method} ${r.url}`)); ``` `cache.keys()` resolves with the actual stored `Request` objects in insertion order. Seeing `GET https://example.com/app.js?v=1749` next to a lookup for `/app.js` ends the investigation instantly. `cache.matchAll(request, options)` is the companion for the opposite problem — several entries matching one request, typically when `ignoreSearch` or `ignoreVary` is in play — and returns all of them rather than the first. ## The mirror-image failure The same options cause *false hits*. `ignoreSearch: true` on a paginated API returns page 1's body for a page-2 request. `ignoreVary: true` on a content-negotiated endpoint can hand JSON to a request that asked for HTML. Treat both as narrow tools for cases where you have proved the variation is irrelevant, not as a default that makes lookups "work".

  • Why does `Vary` affect Cache API lookups when `Cache-Control` is ignored completely?
    They answer different questions. `Cache-Control` is about freshness — when an entry stops being usable — and the Cache API delegates that entirely to your code. `Vary` is about identity: it says the stored bytes are only correct for requests with those header values. Since matching is the one thing the API does decide, `Vary` is the one header it must honour.
  • What is the difference between `cache.match()` and `caches.match()`?
    `cache.match()` searches one `Cache` you already opened. `caches.match()` is on `CacheStorage` and searches every named cache for the origin, in creation order, returning the first hit. It takes the same options plus `cacheName` to restrict the search. The plural form is convenient in a fetch handler but hides which generation answered.
  • When would you use `cache.matchAll()` instead of `cache.match()`?
    When one request can legitimately correspond to several entries — for example with `ignoreSearch: true` across many query variants, or with `ignoreVary: true` where several negotiated representations are stored. `matchAll` resolves with every match rather than the first, which lets you pick one, or delete a whole family of entries during invalidation.

saying these in an interview costs you the question

  • Assumes cache lookups compare only the path, not the query string
  • Says the Cache API ignores every response header including Vary
  • Thinks ignoreSearch is a harmless default to always enable
  • Expects a POST request to match a stored entry
  • Confuses cache.match with caches.match across all caches

context

open as a page

The browser gives you the Cache API (the global `caches` / `CacheStorage`) alongside the HTTP cache it manages itself. How do the two differ, and what does that difference mean for code that stores responses?

level: middleimportance: must knowfreq 68%

basics

~20 s

The Cache API is a script-owned store of Request/Response pairs that you fill, look up and delete explicitly, with no expiry of its own. The HTTP cache is browser-managed, driven by response headers, and unreachable from JavaScript.

open as a page

A `Cache` object from the browser's Cache API offers `add(request)`, `addAll(requests)` and `put(request, response)`. What does each one do, and when do you have to use `put`?

level: juniorimportance: should knowfreq 44%

basics

~20 s

add(request) fetches a URL and stores the result; addAll(list) does that for several URLs and stores none if any fails. put(request, response) stores a Response you already hold — the only option when you already fetched it.

open as a page

Your app stores cross-origin assets fetched with `mode: 'no-cors'` in the browser's Cache API and starts hitting `QuotaExceededError` even though the files are small. What is an opaque response, and why does storing them behave this way?

level: seniorimportance: should knowfreq 42%

basics

~20 s

A no-cors cross-origin fetch yields an opaque response: status 0, no readable headers or body. Browsers pad its recorded storage size — in Chromium, to roughly 7 MB apiece — so a handful of small opaque entries can exhaust an origin's quota.

open as a page

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?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Nothing 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.

open as a page