Skip to content

The Cache Storage API

The Cache Storage API (self.caches) is the script-controlled store of HTTP request/response pairs that service workers use to serve a Progressive Web App offline. It is defined in the Caches section of the Service Workers specification, is exposed in windows and every kind of worker in secure contexts, and ships in all current engines. Unlike the HTTP cache it has no freshness model at all: an entry stays exactly as you wrote it until your code deletes it or the browser evicts the whole origin. This page documents every method down to the algorithm steps, explains exactly how requests are matched, and then builds the parts the API leaves out: expiration, LRU trimming, size measurement, quota handling and cache migrations.

Key takeaways

  • caches (a CacheStorage) manages an ordered set of named caches. Each Cache holds an ordered list of Request/Response pairs. Every method is promise-based, and nothing in the API is synchronous.
  • Matching compares the URL without its fragment, the method (only GET matches unless ignoreMethod), and the request headers named in the cached response's Vary. Cookies, credentials, request mode and destination are not part of the key.
  • add()/addAll() fetch, validate and store atomically. They reject with a TypeError if any response is a network error, is not 2xx, is a 206, or carries Vary: *. put() stores almost anything, including 404s and opaque responses.
  • Stored Cache-Control, Expires and ETag headers are kept but ignored. Expiration, LRU eviction and revalidation are your job, and the usual approach keeps metadata in IndexedDB.
  • Opaque responses can be stored but not inspected. Chromium pads each one by a pseudo-random 0 to about 14 MiB of quota (about 7 MiB on average), and a Cross-Origin-Resource-Policy check can make match() reject for them.
  • For speed, query a named cache instead of caches.match(), avoid keys() on very large caches, and never block respondWith() on a cache write. Use event.waitUntil() instead.

Where Cache Storage fits in the platform

The Service Workers specification states the API's contract bluntly: caches "are not shared across origins, and they are completely isolated from the browser's HTTP cache." It also states that cache objects are not updated unless authors explicitly request it, "do not expire unless authors delete the entries," and do not disappear when the service worker script is updated. Everything else on this page follows from those three properties.

Internally, a CacheStorage object represents a name to cache map stored in the "caches" storage bottle of your storage key, as defined by the WHATWG Storage Standard. In practice that means:

  • Scope is the origin, not the service worker. Every page, worker and service worker registration on https://app.example.com sees the same set of caches. Two service workers with different scopes on one origin share them, and so can clobber each other's caches if they use the same names.
  • Third-party contexts are partitioned. An iframe from https://widget.example embedded on https://news.example gets a different Cache Storage than the same origin loaded at top level. Chrome shipped this partitioning in Chrome 115, and the other engines partition too. See Privacy & Storage Partitioning.
  • Quota is shared. Cache Storage counts against the same per-origin quota as IndexedDB, OPFS and service worker registrations, and it is evicted together with them. See Storage Quotas & Persistence.
  • It is not tied to the service worker's lifecycle. Unregistering a service worker does not delete its caches. Updating a service worker does not either, which is why the activate event is the conventional place to delete old ones.

Availability in windows, workers and service workers

The caches attribute is declared on the WindowOrWorkerGlobalScope mixin with [SecureContext]. Both Cache and CacheStorage are [Exposed=(Window,Worker)].

Context caches available Notes
Window (secure context) ✅ Top-level documents and iframes. Window code can populate caches directly, for example for "save for offline" features.
Dedicated worker ✅ Useful for bulk downloads and cache maintenance away from UI code.
Shared worker ✅ Same store as every other context of the origin.
Service worker ✅ Requests made by add()/addAll() here skip the service worker (the spec sets the request's service-workers mode to "none").
Worklets (audio, paint, animation) ❌ Worklet global scopes do not include WindowOrWorkerGlobalScope.
Insecure context (http:// other than localhost) ❌ The attribute does not exist, so "caches" in self is false.
Opaque origins (sandboxed iframe without allow-same-origin, data: documents) ⚠️ There is no usable storage key. Expect operations to fail, typically with a SecurityError.

Feature-detect with the in operator rather than by touching the property inside a try block:

feature-detect.js
export const hasCacheStorage = typeof self !== "undefined" && "caches" in self;

if (!hasCacheStorage) {
  // Insecure origin or very old engine: run network-only and skip offline features.
}

The data model: named caches holding ordered entry lists

classDiagram
    class CacheStorage {
        +open(cacheName)
        +has(cacheName)
        +delete(cacheName)
        +keys()
        +match(request, options)
    }
    class Cache {
        +match(request, options)
        +matchAll(request, options)
        +add(request)
        +addAll(requests)
        +put(request, response)
        +delete(request, options)
        +keys(request, options)
    }
    class Entry {
        +Request request
        +Response response
    }
    CacheStorage "1" o-- "many" Cache : name to cache map
    Cache "1" o-- "many" Entry : request response list

Three structural details from the spec have practical consequences:

  1. Both levels are ordered. The name to cache map is an ordered map, so caches.keys() returns names in creation order. A cache's request response list is a list, so cache.keys() and cache.matchAll() return entries in insertion order. Re-putting an existing key removes the old entry and appends the new one at the end. That makes keys() a free FIFO queue, as shown in Implementing LRU and size limits.
  2. Cache objects are handles. Many Cache objects, in many contexts, can represent the same underlying list at the same time. Creating one is cheap. The expensive part is the storage work behind each method.
  3. Deleting a cache orphans live handles. The spec notes that after caches.delete(name), existing Cache, Request and Response objects "should remain functional." A handle you obtained before the deletion still accepts writes, but it now points at a list no one can reach by name, and those writes vanish. This is the mechanism behind a classic bug: an activate handler deletes a cache while a fetch handler in the same worker is still writing to a memoized handle for it.

CacheStorage: managing named caches

Method Resolves with Behavior
caches.open(cacheName) Cache Returns the named cache and creates it if it does not exist. Can reject with QuotaExceededError when creation fails for quota reasons.
caches.has(cacheName) boolean true if a cache with exactly that name exists. Never creates one.
caches.delete(cacheName) boolean Removes the cache and all its entries. false if it did not exist.
caches.keys() string[] All cache names, in the order they were created.
caches.match(request, options) Response or undefined Searches caches in creation order and resolves with the first hit. options.cacheName restricts the search to one cache.

Cache names are arbitrary strings compared exactly: case-sensitive, with no normalization. The name is also the only metadata a cache has. There is no creation date and no size, so encode anything you need (app ID, purpose, version) in the name itself.

caches.open(): get or create

open() never fails because a cache is missing, so it cannot be used as an existence check. Code that "reads" from await caches.open("pages") in a page that has never been cached silently creates an empty cache named pages. Use caches.has() first when creating the cache would be a side effect you do not want, for example in diagnostics code or in window code that runs before the service worker has installed.

open-or-skip.js
/** Resolve with the cache only if the service worker already created it. */
export async function openExisting(cacheName) {
  return (await caches.has(cacheName)) ? caches.open(cacheName) : null;
}

caches.delete() and caches.keys(): cleanup

delete() resolves with false for a cache that does not exist, so it is safe to call unconditionally. The standard cleanup pattern filters keys() by a prefix you own. Filtering by prefix matters because other code on the same origin may own caches too, for example a second app, a different service worker scope, or a library such as Workbox with its own naming scheme.

sw.js
const APP_PREFIX = "acme-";
const CURRENT_CACHES = new Set([
  `${APP_PREFIX}static-2026-09-25`,
  `${APP_PREFIX}pages-v4`,
  `${APP_PREFIX}images-v2`,
]);

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const names = await caches.keys(); // creation order
      const stale = names.filter(
        (name) => name.startsWith(APP_PREFIX) && !CURRENT_CACHES.has(name),
      );
      await Promise.all(stale.map((name) => caches.delete(name)));
    })(),
  );
});

caches.match(): searching every cache

caches.match(request, options) accepts MultiCacheQueryOptions, which extends the per-cache options with a cacheName member. The spec's algorithm has two branches:

  • With cacheName, it finds that cache and runs Cache.match() on it. If no cache has that name, it resolves with undefined. Unlike open(), it does not create one.
  • Without cacheName, it chains one Cache.match() per cache, sequentially, in creation order, and resolves with the first response found.

Two consequences follow. The lookup cost grows with the number of caches, since each miss is a separate query. And when two caches hold the same URL (for example static-v1 and static-v2 during an update), the older cache wins because it was created first. Prefer (await caches.open(name)).match(request) or caches.match(request, { cacheName }) on hot paths, and delete superseded caches promptly.

Cache: reading entries

Cache interface (Service Workers spec)
[SecureContext, Exposed=(Window,Worker)]
interface Cache {
  [NewObject] Promise<(Response or undefined)> match(RequestInfo request, optional CacheQueryOptions options = {});
  [NewObject] Promise<FrozenArray<Response>> matchAll(optional RequestInfo request, optional CacheQueryOptions options = {});
  [NewObject] Promise<undefined> add(RequestInfo request);
  [NewObject] Promise<undefined> addAll(sequence<RequestInfo> requests);
  [NewObject] Promise<undefined> put(RequestInfo request, Response response);
  [NewObject] Promise<boolean> delete(RequestInfo request, optional CacheQueryOptions options = {});
  [NewObject] Promise<FrozenArray<Request>> keys(optional RequestInfo request, optional CacheQueryOptions options = {});
};

dictionary CacheQueryOptions {
  boolean ignoreSearch = false;
  boolean ignoreMethod = false;
  boolean ignoreVary = false;
};

RequestInfo is Request or USVString. When you pass a string, the method runs the Request constructor on it. A relative URL therefore resolves against the global's base URL: the document base URL in a window, or the worker script's URL in a worker. An unparseable URL makes the returned promise reject with that constructor's TypeError.

cache.match(request, options)

match() is defined as "run matchAll() and return the first element, or undefined." Two details are easy to miss:

  • If request is a Request whose method is not GET and ignoreMethod is false, the result is undefined immediately, without touching storage. This is why a naive cache-first handler silently never serves POST requests from cache.
  • Every call returns a new Response object whose Headers have the "immutable" guard. You can read the body once per object. To use a hit twice, for example to respond and also inspect it, call clone() before consuming either copy. To change a header, construct a new Response.

cache.matchAll(request, options)

matchAll() resolves with a frozen array of every matching response, in insertion order. With no arguments it returns every response in the cache. It is the only way to see all variants stored for one URL (see Vary), and with ignoreSearch it lists all entries for a path regardless of query string.

Before resolving, matchAll() (and therefore match()) runs a Cross-Origin-Resource-Policy check on every opaque response it found, against the calling context. If the check blocks any of them, the whole promise rejects with a TypeError. This check blocks when a stored cross-origin response carries a Cross-Origin-Resource-Policy header that excludes your origin, and when your context requires CORP (a cross-origin-isolated page or worker with Cross-Origin-Embedder-Policy: require-corp) and the response has none.

cache.keys(request, options)

keys() mirrors matchAll() but returns Request objects (also with immutable headers). Without arguments it lists every entry in insertion order. With a request it lists the keys of matching entries, which may be several when ignoreSearch or Vary is involved. The returned requests carry the URL and headers that were stored, so they are the right thing to pass back into delete() or match() when you want to address exactly one stored entry.

How a request is matched against a cache entry

Every read and delete method funnels into the spec's Query Cache algorithm, which walks the entry list in order and tests each entry with Request Matches Cached Item. Given the query request, a cached request, its cached response and the options:

  1. If ignoreMethod is false and the cached request's method is not GET, it is not a match. (The public methods have already returned early for non-GET queries.)
  2. Take both URLs. If ignoreSearch is true, set both URLs' query to the empty string.
  3. If the URLs differ when compared with fragments excluded, it is not a match.
  4. If the cached response is null, ignoreVary is true, or the cached response has no Vary header, it is a match.
  5. Otherwise, for each field name in the cached response's Vary header: if it is *, or the cached request's combined value for that header differs from the query request's combined value, it is not a match.
  6. Otherwise it is a match.

URL comparison, fragments and query strings

URLs are compared as serialized, parsed URLs, so the parser's normalization applies (lowercased scheme and host, default ports removed, spaces percent-encoded). Beyond that, the comparison is exact:

Query URL Stored URL Match? Why
/article#comments /article ✅ Fragments are excluded on both sides.
HTTPS://Example.com:443/a https://example.com/a ✅ URL parsing normalizes scheme, host and default port.
/docs /docs/ ❌ Path strings differ.
/list?a=1&b=2 /list?b=2&a=1 ❌ Query strings are compared as strings, not as parameter sets.
/list?a=1 /list ❌ Unless ignoreSearch: true.
/index.html / ❌ The cache knows nothing about your server's default document.

ignoreSearch

ignoreSearch: true empties the query on both sides, so /search?q=pwa matches an entry stored as /search?q=cache and vice versa. It is a blunt instrument:

  • With several entries for the same path, match() returns the first inserted, which is rarely what you want.
  • It cannot express "ignore utm_* but keep page". To do that, normalize URLs yourself before both writing and reading. See Normalizing cache keys.
  • Implementations index entries by URL, so a lookup that ignores the query can force a scan of the whole cache. Use it on small caches, such as an app shell whose HTML is requested with tracking parameters appended.

The HTTP cache now has a precise tool for this problem: the No-Vary-Search response header, which the HTTP cache honors in Chrome 141 and later and Firefox 154 and later (Safari does not support it as of September 2026). Cache Storage does not read that header.

ignoreMethod

Only GET requests can be stored (put() and addAll() reject anything else), so ignoreMethod: true exists to let a HEAD, POST or other query match a stored GET entry. For HEAD this is harmless: Fetch's main fetch algorithm nulls the body of any response to a HEAD request, including one your worker supplies. For POST it is almost always wrong, since a POST is not a request for the representation at that URL. To cache responses to idempotent POST queries such as GraphQL reads, derive a synthetic GET key. See Caching POST responses under a synthetic key.

Vary and ignoreVary

Vary handling is the least understood part of the API, because it compares the header lists of Request objects, not the headers that went over the wire. The Fetch Standard divides header setting into layers. Accept and Accept-Language are set in the early fetch layer, before the service worker sees the request. Most other headers controlled by the user agent, such as Accept-Encoding, Host and Referer, are set in the network and cache layer. Cookie, Origin and User-Agent are also appended there. Headers from that later layer are absent from both the stored and the query Request, so a Vary on them compares "absent" with "absent" and matches.

Vary on the cached response Effect on Cache Storage matching
Accept-Encoding No practical effect. Neither Request object carries the header.
User-Agent No practical effect, for the same reason.
Origin No practical effect. This is also why a no-cors and a cors request for the same URL share one key (see below).
Cookie No protection. Responses personalized by cookie match for every user of the device. Clear such caches on logout.
Accept Can cause misses. Navigations carry the browser's HTML Accept value. A request created from a bare URL string in put() carries none, and one fetched by add() may carry a default. If your server sends Vary: Accept, write and read with the same Request or use ignoreVary.
Authorization, X-Api-Version or other headers your code sets Enforced. The stored and query requests must carry the same value.
* put(), add() and addAll() reject with a TypeError.

Because put() only replaces entries that match the new request under the stored response's Vary, a cache can hold several entries for one URL, one per variant. keys() then lists duplicates, match() returns the first variant that matches, and delete() without ignoreVary removes only the matching variant. When you know variants are irrelevant to your app, pass ignoreVary: true on reads and deletes, or strip Vary before storing.

strip-vary.js
/** Remove Vary so one URL maps to exactly one entry. Only for responses you fully control. */
export function withoutVary(response) {
  if (!response.headers.has("Vary") || response.type === "opaque") return response;
  const headers = new Headers(response.headers);
  headers.delete("Vary");
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

What is not part of the cache key

Request property Part of the match? Consequence
URL without fragment ✅ Primary key.
Method ✅ Only GET stored; other methods need ignoreMethod.
Headers named in the response's Vary ✅ See the table above.
mode (cors, no-cors, navigate, same-origin) ❌ An opaque response cached for <img src> can be returned for a later fetch() of the same URL in cors mode, and respondWith() then fails with a network error. Keep cross-origin cors and no-cors traffic in separate caches, or make both use CORS.
credentials and cookies ❌ Anonymous and credentialed responses collide. Per-user data leaks across logins on shared devices.
destination ❌ A script and a fetch() of the same URL share one entry.
integrity ❌ Not used for matching. The page's fetch still verifies Subresource Integrity on the response your worker returns, so a stale cached copy of an SRI-protected file fails to load instead of executing.
cache, redirect ❌ Not stored semantics. The redirect mode matters when you use the response, as described in Redirected responses and navigations.

Personalized responses on shared devices

Because cookies and credentials are not part of the key, a response rendered for one signed-in user is served to whoever uses the same browser profile next. Keep per-user responses in dedicated caches (for example with the account ID in the cache name), delete them on logout, and send Clear-Site-Data: "storage" from the logout response as a second line of defense. The broader threat model is covered in Service Worker Security.

Cache: writing entries

cache.put(request, response)

put() is the low-level primitive: you bring a Response, and the cache stores it under the request. The spec's steps, in order:

  1. If request is a string, construct a Request from it. A constructor failure rejects the promise.
  2. Reject with a TypeError if the URL scheme is not http or https (for example chrome-extension:, data: or blob:), or if the method is not GET.
  3. Reject with a TypeError if the response status is 206. Partial content is never stored.
  4. Reject with a TypeError if the response's Vary header contains *.
  5. Reject with a TypeError if the response body is disturbed or locked, which is the "Response body is already used" error.
  6. Clone the response, then read the entire body of the one you passed in. Your Response is consumed after this call.
  7. When the body has been fully read, run a batch operation that removes every existing entry matching the request (Vary-aware, default options) and appends the new pair. If the write fails because of quota, the promise rejects with a QuotaExceededError and the batch rolls back.

What put() does not check matters as much. Any status except 206 is accepted: 404s, 500s, redirects that were followed, and opaque responses with status 0. Cache-Control: no-store is ignored too. Filter responses yourself before storing them:

storable.js
/**
 * Decide whether a network response is safe to keep in Cache Storage.
 * Cache Storage enforces none of these rules itself.
 */
export function isStorable(response, { allowOpaque = false } = {}) {
  if (response.type === "opaque") return allowOpaque; // status is unknowable
  if (response.status !== 200) return false; // no errors, no 206, no 204
  const cacheControl = response.headers.get("Cache-Control") ?? "";
  if (/\bno-store\b/i.test(cacheControl)) return false; // the server asked us not to persist it
  if ((response.headers.get("Vary") ?? "").split(",").some((v) => v.trim() === "*")) {
    return false; // put() would reject anyway
  }
  return true;
}

The canonical write pattern clones the response before either consumer reads it, and moves the write off the response's critical path with waitUntil():

sw.js
import { isStorable } from "./storable.js";

self.addEventListener("fetch", (event) => {
  if (event.request.method !== "GET") return;
  event.respondWith(
    (async () => {
      const response = await fetch(event.request);
      if (isStorable(response)) {
        const copy = response.clone(); // (1)!
        event.waitUntil(caches.open("runtime-v1").then((c) => c.put(event.request, copy))); // (2)!
      }
      return response;
    })(),
  );
});
  1. clone() tees the body stream. If one branch is read faster than the other, the unread data is buffered in memory, so a slow page reading a huge response while put() reads quickly can hold the whole body in memory. For very large media, consider caching from a separate fetch or streaming to OPFS instead.
  2. Calling waitUntil() asynchronously is allowed here because the promise passed to respondWith() is still pending, which keeps the event active. The worker is not terminated until the write completes.

cache.add() and cache.addAll(): fetch and store atomically

add(request) is literally addAll([request]). The addAll(requests) algorithm is where most of the API's guarantees live:

  1. Pre-validation. For every Request object in the list, reject with a TypeError if its scheme is not http/https or its method is not GET. This happens before any network activity.
  2. Request construction. Each item is run through the Request constructor. Strings therefore become requests with mode: "cors", credentials: "same-origin" and cache: "default". A bad scheme aborts all fetches started so far and rejects.
  3. Service worker bypass. If the caller is a service worker, the request's service-workers mode is set to "none". If the caller is a controlled page, nothing is bypassed, so the request goes through your own fetch handler, which might answer it from the cache you are trying to fill.
  4. Parallel fetches. When each response arrives, the promise for that request rejects with a TypeError if the response is a network error, its status is not in 200–299, or it is 206. If it carries Vary: *, it rejects and aborts all the other fetches.
  5. Full bodies. Each request's promise resolves only when its body has been completely received. The spec notes that "the cache commit is allowed when the response's body is fully received." An aborted body rejects with an AbortError.
  6. One atomic batch. When all responses are complete, every put runs in a single Batch Cache Operations job. If two requests in the batch match each other (the same URL, since fragments are excluded), it throws an InvalidStateError. If storage fails for quota reasons, it throws a QuotaExceededError. Any exception rolls back the whole batch, leaving the cache as it was.

The practical consequences:

  • All or nothing. Either every response is stored or none is. This makes addAll() the natural primitive for precaching an app shell in install: a rejected promise passed to event.waitUntil() fails the installation and the previous worker stays in control.
  • A failure does not stop other downloads. For a non-OK status, the spec rejects that request's promise but does not abort the siblings. They may keep downloading even though the batch can never commit.
  • No timeout, no retry, no AbortSignal. A hung request hangs the install. If you need control, fetch yourself (with AbortSignal.timeout()), validate, and put() into a fresh versioned cache. The cache name then becomes your unit of atomicity, as in Migrating entries between cache versions.
  • The HTTP cache is consulted. With the default cache mode, addAll() happily stores a stale HTTP-cached copy. Pass new Request(url, { cache: "reload" }) for unversioned URLs.
  • Redirects are followed and remembered. The stored response has redirected === true, which breaks it for navigations (see below).
  • Opaque responses cannot be added. A no-cors request produces status 0, which is not in 200–299, so add() rejects. Use fetch() plus put().
  • Duplicates are errors. addAll(["/", "/#top"]) rejects with InvalidStateError. De-duplicate your manifest after removing fragments.
precache.js
/**
 * Atomically precache a list of URLs into a versioned cache.
 * Throws (and leaves no partial cache behind) if any URL fails.
 */
export async function precache(cacheName, urls) {
  const unique = new Set();
  for (const url of urls) {
    const absolute = new URL(url, self.location.href);
    absolute.hash = ""; // addAll() treats "/a" and "/a#x" as duplicates
    unique.add(absolute.href);
  }

  const requests = [...unique].map(
    (href) => new Request(href, { cache: "reload", credentials: "same-origin" }),
  );

  const existed = await caches.has(cacheName);
  const cache = await caches.open(cacheName);
  try {
    await cache.addAll(requests);
  } catch (error) {
    // addAll() rolled its own batch back, but open() may have created an empty cache.
    if (!existed) await caches.delete(cacheName);
    throw error; // reject install so the old service worker keeps control
  }
}
add() / addAll() put()
Who fetches The cache, using default request settings You
Accepts non-2xx responses ❌ TypeError ✅ Any status except 206
Accepts opaque responses ❌ (status 0 is not OK) ✅
Atomic across several URLs ✅ One batch ❌ One entry per call
Goes through your fetch handler From a controlled page, yes. From the service worker, no Not applicable
Stores Vary: * ❌ ❌
Resolves when All bodies are downloaded and committed Body is read and committed

cache.delete(request, options)

delete() resolves with true if at least one entry was removed. It honors all three options: ignoreSearch deletes every entry for the path, and ignoreVary deletes every variant of the URL. A non-GET Request query without ignoreMethod resolves false immediately. Deleting by the Request objects returned from keys() addresses exactly the stored entry, including its variant.

Atomicity, concurrency and locking

Each put(), delete() and addAll() call is one atomic batch against one cache. The API has no multi-operation transactions and no cross-cache atomicity, and pages and workers run concurrently against the same caches. Individual writes never tear, but read-modify-write sequences race. Two tabs that each run "list keys, delete the oldest ten" will delete twenty. So will a tab and the service worker.

When a sequence must not interleave, serialize it with the Web Locks API, which is available in windows and all workers (Chrome 69, Firefox 96, Safari 15.4):

locked-trim.js
export function withCacheLock(cacheName, task) {
  const locks = self.navigator?.locks;
  if (!locks) return task(); // very old engines: accept the race
  return locks.request(`cache:${cacheName}`, task);
}

// Usage: await withCacheLock("images-v2", () => trimCache("images-v2", 200));

What a cache entry actually stores

A cache entry holds the request and a copy of the full response, not just the body. When you read it back:

Property of the returned Response Value
status, statusText, ok As stored. A cached 404 is still a 404.
headers The stored header list with an immutable guard. For cors responses only the CORS-safelisted headers (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma) plus those in Access-Control-Expose-Headers are visible. Opaque responses show none.
type "basic", "cors" or "opaque" as fetched, or "default" for a Response you constructed.
url The final URL after redirects, taken from the stored URL list. It can differ from the cache key.
redirected true if the URL list has more than one entry.
Body Read from storage when you consume it. Each returned Response object's body can be read once.

Several things are not stored or exposed. No API returns the time an entry was written (DevTools shows a "Time Cached" column from internal metadata), and nothing records HTTP cache age, connection info or timing. The body is stored as the decoded payload: fetch() already removed any Content-Encoding. The Content-Length header on a compressed response still states the encoded transfer size, so summing Content-Length values underestimates storage use for text assets.

Headers are stored but mean nothing to the cache

Cache-Control, Expires, ETag, Last-Modified and Age survive in the stored response, and you can use them to implement freshness or revalidation in your own code, as shown in Revalidating with ETag and Last-Modified. One caveat for cross-origin data: Date is not a CORS-safelisted response header, so it is invisible on a cors response unless the server lists it in Access-Control-Expose-Headers. Freshness logic based on Date quietly fails for CDN-hosted JSON.

Redirected responses and navigations

Navigation requests use redirect: "manual". When a service worker answers one, Fetch returns a network error if the response's URL list has more than one item, which is true of any response obtained by following a redirect. Chrome reports this in the console as a redirected response used for a request whose redirect mode is not "follow". This bites when you precache / and the server redirects it to /en/, or when an auth gateway redirects. Fix it by storing, or serving, a fresh Response built from the same parts:

clean-redirect.js
/** Return an equivalent response with an empty URL list so it can answer navigations. */
export async function cleanRedirect(response) {
  if (!response.redirected) return response;
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });
}

A constructed Response has an empty URL list, type: "default" and url: "". When it is used for a request, Fetch fills in the request's URL, so relative URLs inside the document resolve against the URL the user navigated to. That is almost always what you want, but it is worth knowing if the redirect target lived at a different path.

Streaming bodies and memory

put() must read the whole body before it commits, and addAll() waits for complete bodies too. The spec permits implementations to stream to disk rather than buffer in memory. On the read side, a matched response's body is a stream backed by storage, so serving a 50 MB video from cache does not require 50 MB of JavaScript heap unless you call arrayBuffer() or text(). Keep bodies as streams (respondWith(cachedResponse)) wherever possible, and use Blob.slice() rather than arrayBuffer() when you need a byte range (see Serving Range requests from a cached response).

Opaque responses

A request in no-cors mode to another origin that does not grant CORS produces an opaque filtered response. Its type is "opaque", status is 0, statusText is empty, headers is empty, url is "", and the body is unreadable from script. Elements without a crossorigin attribute (<img>, classic <script>, <link rel="stylesheet">, <video>) make exactly these requests, so a service worker that caches "everything cross-origin" collects many of them.

How the resource is requested Request mode Can an opaque cached response answer it?
<img src>, classic <script src>, <link rel="stylesheet"> without crossorigin no-cors ✅
fetch(url, { mode: "no-cors" }) no-cors ✅, but script cannot read it
fetch(url) (default), <img crossorigin>, <script type="module">, web fonts cors ❌ respondWith() yields a network error
Navigations navigate ❌

Because request mode is not part of the cache key, one URL requested both ways can end up with a cors request being answered by an opaque entry, which fails. Keep such URLs consistently in one mode.

Opaque responses have three costs:

  1. You cannot tell success from failure. A CDN's 404 or 503 is indistinguishable from a 200. Cache one in a cache-first route and you serve the error forever. Only cache opaque responses under strategies that refresh them (network-first or stale-while-revalidate), with a short expiration.
  2. They inflate quota usage. To avoid leaking the size of cross-origin resources through the quota APIs, browsers pad opaque responses. Chromium adds a pseudo-random padding between 0 and about 14 MiB to each opaque response, about 7 MiB on average, whatever its real size (older Workbox documentation calls this a "7 megabytes" minimum; the current implementation is random with that average). Firefox has padded opaque responses in its DOM Cache implementation since Firefox 57, so their reported size no longer reveals the real one. A hundred cached third-party avatars can therefore count as roughly 700 MB in Chromium and push the origin toward eviction.
  3. Reads can fail. As described under matchAll(), the Cross-Origin-Resource-Policy check can make match() reject with a TypeError for opaque entries, notably in cross-origin-isolated contexts.

The fix is almost always to request with CORS: add crossorigin="anonymous" to the element (the server must send Access-Control-Allow-Origin), or fetch() in the default cors mode. You then get a readable cors response with a real status and headers.

sw.js
// Cache third-party images only when they come back as readable CORS responses.
self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);
  if (request.destination !== "image" || url.origin === self.location.origin) return;

  event.respondWith(
    (async () => {
      const cache = await caches.open("third-party-images-v1");
      const cached = await cache.match(request);
      if (cached) return cached;

      const response = await fetch(request);
      // Opaque (status 0) responses are served but never stored: no status, ~7 MiB average quota padding each.
      if (response.type === "cors" && response.ok) {
        event.waitUntil(cache.put(request, response.clone()));
      }
      return response;
    })(),
  );
});

Why Cache Storage has no HTTP expiry semantics

The HTTP cache may discard, revalidate or ignore any entry at any time. That is correct for an optimization layer, but it is fatal for an offline app, which needs to know that the file it cached during install will be there when the network is gone. Cache Storage therefore makes the opposite trade: it is deterministic. Nothing in it changes unless your code changes it, and the only non-deterministic event is eviction of the entire origin.

The price is that every HTTP caching feature becomes your responsibility:

HTTP cache feature (RFC 9111) In Cache Storage Implement it with
Freshness from max-age, s-maxage, Expires Stored, ignored Timestamps plus an age check at read time
Heuristic freshness from Last-Modified None Your own policy
Conditional revalidation (ETag, Last-Modified, 304) None A conditional fetch() and a metadata refresh
stale-while-revalidate directive Ignored The stale-while-revalidate strategy
no-store, private Ignored by put() A storability check before writing
Vary Honored, against Request objects Consistent request construction, or ignoreVary
No-Vary-Search (HTTP cache in Chrome 141+, Firefox 154+) Ignored Normalizing URLs before reading and writing
Per-entry eviction under pressure Never. Only the whole origin is evicted maxEntries, LRU and byte budgets
Range requests and 206 206 cannot be stored Build 206 responses from a stored 200

Revalidating with ETag and Last-Modified

You can get the bandwidth savings of HTTP revalidation for a Cache Storage entry by sending the validators yourself. The Fetch Standard switches a request's cache mode from "default" to "no-store" when it carries If-None-Match, If-Modified-Since or other conditional headers. The HTTP cache is then bypassed, and a 304 Not Modified reaches your code as a real 304 with a null body.

revalidate.js
/**
 * Revalidate a cached entry with its validators.
 * Returns the fresh or still-valid response, updating the cache as needed.
 */
export async function revalidate(cacheName, request) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  // new Request(request, init) turns mode "navigate" into "same-origin", so this also works for pages.
  const headers = new Headers(request.headers);
  const etag = cached?.headers.get("ETag");
  const lastModified = cached?.headers.get("Last-Modified");
  if (etag) headers.set("If-None-Match", etag);
  else if (lastModified) headers.set("If-Modified-Since", lastModified);

  let response;
  try {
    response = await fetch(new Request(request, { headers }));
  } catch (networkError) {
    if (cached) return cached; // offline: fall back to what we have
    throw networkError;
  }

  if (response.status === 304 && cached) {
    // Still valid. Refresh your freshness metadata here (for example recordStored() below).
    return cached;
  }
  if (response.ok) {
    await cache.put(request, response.clone());
  }
  return response;
}

If-None-Match and If-Modified-Since are not CORS-safelisted request headers, so this triggers a preflight for cross-origin URLs. Use it for same-origin APIs and pages.

Implementing expiration

There are four common designs. They differ in granularity, in whether they work for opaque responses, and in how much bookkeeping they need.

Design Granularity Works for opaque responses Extra storage Read cost
Versioned cache names Whole cache, per deploy ✅ None None
Time-bucketed cache names Whole bucket (day, week) ✅ None One or two lookups
Timestamp header inside the stored response Per entry ❌ (cannot rebuild opaque) A few bytes per entry Header parse
Metadata in IndexedDB Per entry, plus LRU and sizes ✅ One small record per entry One IndexedDB read

Versioned cache names

For precached build output, expiration is a deploy concern. Put the build ID or a content hash in the cache name, and delete every cache with your prefix that the current worker does not list during activate (the cleanup code is shown under caches.delete() and caches.keys()). This is the only design that needs no per-entry bookkeeping, and it is what build tools generate. See Precaching & Runtime Caching.

Time-bucketed cache names

For runtime caches where approximate expiry is enough, write entries into a cache named after the current time window and delete whole windows as they age out. Deleting a cache is one call, however many entries it holds.

bucketed-cache.js
const PREFIX = "acme-api-";
const BUCKET_MS = 24 * 60 * 60 * 1000; // one bucket per UTC day
const KEEP_BUCKETS = 7; // about a week of history

const bucketName = (time = Date.now()) => `${PREFIX}${Math.floor(time / BUCKET_MS)}`;

export async function putBucketed(request, response) {
  const cache = await caches.open(bucketName());
  await cache.put(request, response);
}

export async function matchBucketed(request) {
  // Newest first: today's bucket, then older ones still within the window.
  const now = Date.now();
  for (let i = 0; i < KEEP_BUCKETS; i += 1) {
    const name = bucketName(now - i * BUCKET_MS);
    const hit = await caches.match(request, { cacheName: name }); // does not create the cache
    if (hit) return hit;
  }
  return undefined;
}

export async function dropExpiredBuckets() {
  const oldestKept = Math.floor(Date.now() / BUCKET_MS) - (KEEP_BUCKETS - 1);
  const names = await caches.keys();
  await Promise.all(
    names
      .filter((n) => n.startsWith(PREFIX) && Number(n.slice(PREFIX.length)) < oldestKept)
      .map((n) => caches.delete(n)),
  );
}

The trade-off is duplication: an entry refreshed every day is stored once per bucket until old buckets are dropped. Keep the window short or accept the extra storage.

A timestamp header written at put time

Because the whole Response is stored, you can record the write time inside it. This works for basic and cors responses. It cannot work for opaque ones, because the Response constructor only accepts statuses 200–599 and you cannot read an opaque body to copy it.

timestamped.js
const STAMP = "X-SW-Cached-At";
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);

/** Store a copy of the response with the current time in a custom header. */
export async function putWithTimestamp(cache, request, response) {
  if (response.type === "opaque") throw new TypeError("Opaque responses cannot be re-wrapped");
  const headers = new Headers(response.headers);
  headers.set(STAMP, String(Date.now()));
  const stamped = new Response(
    NULL_BODY_STATUSES.has(response.status) ? null : response.body, // Response() throws otherwise
    { status: response.status, statusText: response.statusText, headers },
  );
  await cache.put(request, stamped);
}

/** Age in milliseconds, or Infinity if the entry predates the stamping code. */
export function ageOf(response) {
  const stamp = Number(response.headers.get(STAMP));
  return Number.isFinite(stamp) && stamp > 0 ? Date.now() - stamp : Infinity;
}

export async function matchFresh(cache, request, maxAgeMs) {
  const hit = await cache.match(request);
  return hit && ageOf(hit) <= maxAgeMs ? hit : undefined;
}

Re-wrapping has side effects. The stored response becomes type: "default" with an empty URL list, and redirected is reset, which conveniently fixes the navigation problem. The custom header is also visible to page code that reads the response. And a header cannot drive eviction: to find expired entries you would have to match() every entry and parse headers, which is linear in the cache size. That is why the fourth design exists.

Metadata in IndexedDB

A small IndexedDB store keyed by cache name and URL can record when each entry was written, when it was last read, and how big it is. Expiration then becomes an indexed range query rather than a scan. Workbox's expiration plugin uses this approach. The module below is a dependency-free implementation that works in windows and all workers. Module service workers (type: "module") are supported in Chrome 91, Safari 15 and Firefox 147. Otherwise, bundle the module into a classic worker script.

cache-expiration.js
/**
 * Per-entry expiration, LRU and size bookkeeping for Cache Storage,
 * backed by IndexedDB. Works in windows and all worker types.
 */
const DB_NAME = "cache-expiration";
const DB_VERSION = 1;
const STORE = "entries";

let dbPromise = null;

function openDb() {
  if (dbPromise) return dbPromise;
  dbPromise = new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);
    request.onupgradeneeded = () => {
      const store = request.result.createObjectStore(STORE, { keyPath: "id" });
      // Compound keys let one bounded range scan cover a single cache.
      store.createIndex("byStored", ["cacheName", "storedAt"]);
      store.createIndex("byAccessed", ["cacheName", "accessedAt"]);
    };
    request.onsuccess = () => {
      const db = request.result;
      db.onversionchange = () => {
        // A newer version of this code wants to upgrade: release the connection.
        db.close();
        dbPromise = null;
      };
      resolve(db);
    };
    request.onerror = () => reject(request.error);
  });
  dbPromise.catch(() => {
    dbPromise = null; // never memoize a failure; the next call retries
  });
  return dbPromise;
}

/**
 * Run synchronous request-issuing work inside one transaction.
 * Callbacks may write their result to `out.value`; it is returned on commit.
 */
async function withStore(mode, work) {
  const db = await openDb();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, mode);
    const out = { value: undefined };
    tx.oncomplete = () => resolve(out.value);
    tx.onerror = (event) => reject(event.target?.error ?? tx.error);
    tx.onabort = () => reject(tx.error ?? new DOMException("Transaction aborted", "AbortError"));
    try {
      work(tx.objectStore(STORE), out);
    } catch (error) {
      try {
        tx.abort();
      } catch {
        // already finished
      }
      reject(error);
    }
  });
}

const idOf = (cacheName, url) => `${cacheName} ${url}`;
const everythingIn = (cacheName) => IDBKeyRange.bound([cacheName, 0], [cacheName, Infinity]);

export function recordStored(cacheName, url, size = null) {
  const now = Date.now();
  return withStore("readwrite", (store) => {
    store.put({ id: idOf(cacheName, url), cacheName, url, storedAt: now, accessedAt: now, size });
  });
}

export function getRecord(cacheName, url) {
  return withStore("readonly", (store, out) => {
    const get = store.get(idOf(cacheName, url));
    get.onsuccess = () => {
      out.value = get.result ?? null;
    };
  });
}

export function recordAccessed(cacheName, url) {
  return withStore("readwrite", (store) => {
    const get = store.get(idOf(cacheName, url));
    get.onsuccess = () => {
      if (!get.result) return;
      get.result.accessedAt = Date.now();
      store.put(get.result);
    };
  });
}

export function deleteRecords(cacheName, urls) {
  return withStore("readwrite", (store) => {
    for (const url of urls) store.delete(idOf(cacheName, url));
  });
}

/** All records of one cache, least recently used first. */
export function listByAccess(cacheName) {
  return withStore("readonly", (store, out) => {
    const request = store.index("byAccessed").getAll(everythingIn(cacheName));
    request.onsuccess = () => {
      out.value = request.result;
    };
  });
}

/**
 * URLs to evict: entries stored longer than maxAgeMs ago, then the least
 * recently used entries until at most maxEntries remain.
 */
export function findExpired(cacheName, { maxAgeMs = Infinity, maxEntries = Infinity } = {}) {
  return withStore("readonly", (store, out) => {
    const expired = new Set();
    out.value = expired;

    const trimByCount = () => {
      if (!Number.isFinite(maxEntries)) return;
      const countRequest = store.index("byAccessed").count(everythingIn(cacheName));
      countRequest.onsuccess = () => {
        let remaining = countRequest.result - expired.size;
        if (remaining <= maxEntries) return;
        // Ascending accessedAt order: least recently used first.
        const cursorRequest = store.index("byAccessed").openCursor(everythingIn(cacheName));
        cursorRequest.onsuccess = () => {
          const cursor = cursorRequest.result;
          if (!cursor || remaining <= maxEntries) return;
          if (!expired.has(cursor.value.url)) {
            expired.add(cursor.value.url);
            remaining -= 1;
          }
          cursor.continue();
        };
      };
    };

    if (!Number.isFinite(maxAgeMs)) {
      trimByCount();
      return;
    }
    const cutoff = Date.now() - maxAgeMs;
    if (cutoff <= 0) {
      trimByCount(); // nothing can be that old; also avoids an invalid key range
      return;
    }
    const tooOld = IDBKeyRange.bound([cacheName, 0], [cacheName, cutoff], false, true);
    const ageRequest = store.index("byStored").openCursor(tooOld);
    ageRequest.onsuccess = () => {
      const cursor = ageRequest.result;
      if (cursor) {
        expired.add(cursor.value.url);
        cursor.continue();
      } else {
        trimByCount(); // chained in the same transaction, which is still active here
      }
    };
  });
}

async function countBytes(stream) {
  const reader = stream.getReader();
  let total = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) return total;
    total += value.byteLength;
  }
}

/** put() while counting decoded body bytes on a parallel branch, without buffering the body. */
async function putAndMeasure(cache, request, response) {
  if (response.type === "opaque") {
    await cache.put(request, response);
    return null; // unknowable; quota accounting pads it anyway
  }
  if (!response.body) {
    await cache.put(request, response);
    return 0;
  }
  const probe = response.clone(); // must happen before put() locks the body
  const [, size] = await Promise.all([cache.put(request, response), countBytes(probe.body)]);
  return size;
}

export class ExpiringCache {
  #name;
  #maxAgeMs;
  #maxEntries;
  #touchIntervalMs;
  #running = null;

  constructor(name, { maxAgeSeconds = Infinity, maxEntries = Infinity, touchIntervalSeconds = 60 } = {}) {
    this.#name = name;
    this.#maxAgeMs = maxAgeSeconds * 1000;
    this.#maxEntries = maxEntries;
    this.#touchIntervalMs = touchIntervalSeconds * 1000;
  }

  get name() {
    return this.#name;
  }

  async put(request, response) {
    const url = typeof request === "string" ? new URL(request, self.location.href).href : request.url;
    const cache = await caches.open(this.#name);
    const size = await putAndMeasure(cache, request, response);
    await recordStored(this.#name, url, size);
  }

  /** Exact-URL lookup. Stale entries are returned only when allowStale is true. */
  async match(request, { allowStale = false } = {}) {
    const url = typeof request === "string" ? new URL(request, self.location.href).href : request.url;
    const cache = await caches.open(this.#name);
    const response = await cache.match(request);
    if (!response) return undefined;

    const record = await getRecord(this.#name, url);
    if (!record) {
      // Written by other code, or metadata was lost: adopt it so it expires eventually.
      await recordStored(this.#name, url, null);
      return response;
    }
    if (Date.now() - record.storedAt > this.#maxAgeMs && !allowStale) {
      return undefined; // caller should go to the network; expire() removes it later
    }
    if (Date.now() - record.accessedAt > this.#touchIntervalMs) {
      // Throttled so a hot entry does not cost an IndexedDB write on every hit.
      await recordAccessed(this.#name, url);
    }
    return response;
  }

  /** Evict expired and excess entries. Concurrent calls in one global share a run. */
  expire() {
    this.#running ??= this.#expireOnce().finally(() => {
      this.#running = null;
    });
    return this.#running;
  }

  async #expireOnce() {
    const run = async () => {
      const urls = [
        ...(await findExpired(this.#name, { maxAgeMs: this.#maxAgeMs, maxEntries: this.#maxEntries })),
      ];
      if (urls.length === 0) return 0;
      const cache = await caches.open(this.#name);
      // Delete responses first: an orphaned metadata row is harmless, an orphaned response leaks.
      await Promise.all(urls.map((url) => cache.delete(url, { ignoreVary: true })));
      await deleteRecords(this.#name, urls);
      return urls.length;
    };
    // Serialize across tabs and the service worker when Web Locks exist.
    const locks = self.navigator?.locks;
    return locks ? locks.request(`cache-expiration:${this.#name}`, run) : run();
  }
}

A cache-first image route that uses it:

sw.js
import { ExpiringCache } from "./cache-expiration.js";

const images = new ExpiringCache("acme-images-v2", {
  maxEntries: 300,
  maxAgeSeconds: 30 * 24 * 60 * 60,
});

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "GET" || request.destination !== "image") return;
  if (new URL(request.url).origin !== self.location.origin) return;

  event.respondWith(
    (async () => {
      const cached = await images.match(request);
      if (cached) return cached;

      try {
        const response = await fetch(request);
        if (response.ok) {
          event.waitUntil(images.put(request, response.clone()).then(() => images.expire()));
        }
        return response;
      } catch (error) {
        // Offline and nothing fresh: a stale image beats a broken one.
        const stale = await images.match(request, { allowStale: true });
        if (stale) return stale;
        throw error;
      }
    })(),
  );
});

Two limitations are deliberate. First, the race between findExpired() and the deletes means a URL re-cached by another context in that window can be deleted. The next request simply refetches it. Taking the same lock around every put() would close the gap, at the cost of serializing all writes. Second, the metadata can drift from the cache if other code writes to the same cache or the user deletes one store in DevTools. match() adopts unknown entries, and a periodic reconciliation against cache.keys() can remove orphaned metadata rows.

Implementing LRU and size limits

FIFO trimming with the insertion order of keys()

Because keys() returns entries in insertion order, and a re-put() moves an entry to the end, the first keys are the oldest writes. That gives you a FIFO-by-write-time eviction with no metadata at all:

trim-cache.js
/**
 * Delete the oldest-written entries until at most maxEntries remain.
 * Returns the number of entries removed.
 */
export async function trimCache(cacheName, maxEntries) {
  if (!(await caches.has(cacheName))) return 0;
  const cache = await caches.open(cacheName);
  const requests = await cache.keys(); // insertion order: oldest first
  const excess = requests.length - maxEntries;
  if (excess <= 0) return 0;

  const victims = requests.slice(0, excess);
  // Deleting by the stored Request removes exactly that entry (including its Vary variant).
  const results = await Promise.all(victims.map((request) => cache.delete(request)));
  return results.filter(Boolean).length;
}

Wrap it in withCacheLock() if several contexts trim the same cache. The approach costs one keys() call, which materializes a Request for every entry, so run it after writes rather than on every read, and prefer a metadata-based design for caches with many thousands of entries.

You could approximate LRU with the same trick by re-put()ting an entry on every hit, which moves it to the end. Don't: each re-put rewrites the entire body to disk. Record access times in IndexedDB instead, as ExpiringCache does.

Byte budgets

Entry counts are a poor proxy when entries range from 2 KB icons to 20 MB videos. With sizes recorded at write time, you can enforce a byte budget. Opaque entries have no measurable size, so charge them the padded cost that Chromium bills for them:

byte-budget.js
import { deleteRecords, listByAccess } from "./cache-expiration.js";

const OPAQUE_CHARGE = 7 * 1024 * 1024; // Chromium's documented minimum per opaque response

/** Evict least recently used entries of one cache until its recorded size fits the budget. */
export async function enforceByteBudget(cacheName, maxBytes) {
  // Reuse the module's connection: opening the database here without the upgrade
  // handler could create an empty version 1 database that never gets its store.
  const records = await listByAccess(cacheName); // least recently used first

  // size is null for opaque entries and for entries adopted without measurement.
  // Charging both the padded cost errs toward evicting unmeasured entries first.
  const cost = (r) => (r.size == null ? OPAQUE_CHARGE : r.size);
  let total = records.reduce((sum, r) => sum + cost(r), 0);
  const victims = [];
  for (const record of records) {
    if (total <= maxBytes) break;
    victims.push(record.url);
    total -= cost(record);
  }
  if (victims.length === 0) return 0;

  const cache = await caches.open(cacheName);
  await Promise.all(victims.map((url) => cache.delete(url, { ignoreVary: true })));
  await deleteRecords(cacheName, victims);
  return victims.length;
}

Measuring cache size

Origin-wide usage with navigator.storage.estimate()

navigator.storage.estimate() (Chrome 61, Firefox 57, Safari 17) resolves with { usage, quota } in bytes for the whole storage bucket, including Cache Storage, IndexedDB, OPFS and service worker registrations. Chromium also returns a non-standard usageDetails object that breaks usage down by system, with keys such as caches, indexedDB and serviceWorkerRegistrations. Systems with zero usage are omitted. The numbers are deliberately imprecise ("between compression, deduplication, and obfuscation for security reasons," in MDN's words) and include opaque-response padding.

storage-report.js
export async function storageReport() {
  if (!navigator.storage?.estimate) return null; // Safari before 17
  const { usage, quota, usageDetails } = await navigator.storage.estimate();
  return {
    usageMB: +(usage / 2 ** 20).toFixed(1),
    quotaMB: +(quota / 2 ** 20).toFixed(1),
    percentUsed: +((usage / quota) * 100).toFixed(2),
    cacheStorageMB: usageDetails?.caches ? +(usageDetails.caches / 2 ** 20).toFixed(1) : undefined, // Chromium only
    persisted: (await navigator.storage.persisted?.()) ?? false,
  };
}

Per-cache size by enumeration

No API reports the size of one cache. You have to read every entry, and there is a trap in the obvious shortcut: Content-Length describes the encoded size of a compressed response while the cache stores the decoded body. The function below trusts Content-Length only when there is no Content-Encoding, counts bytes by streaming otherwise, and reports opaque entries separately because their size is unknowable.

measure-cache.js
export async function measureCache(cacheName) {
  if (!(await caches.has(cacheName))) return null;
  const cache = await caches.open(cacheName);
  const responses = await cache.matchAll(); // every entry, insertion order

  let bytes = 0;
  let opaque = 0;
  for (const response of responses) {
    // Sequential on purpose: bounded memory and I/O, even for large caches.
    if (response.type === "opaque") {
      opaque += 1;
      continue;
    }
    const length = response.headers.get("Content-Length");
    if (length !== null && !response.headers.has("Content-Encoding")) {
      bytes += Number(length);
      await response.body?.cancel(); // we did not need the body
      continue;
    }
    if (!response.body) continue;
    const reader = response.body.getReader();
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      bytes += value.byteLength;
    }
  }
  return { cacheName, entries: responses.length, bytes, opaque };
}

Enumerating is I/O-heavy. Run it on demand (a settings screen or a diagnostics panel), not at startup, and prefer sizes recorded at write time for anything that runs routinely.

Handling QuotaExceededError

put(), addAll() and even caches.open() reject with a QuotaExceededError when a write would exceed the quota. The spec rolls the failed batch back, so the cache is unchanged. Web IDL now defines QuotaExceededError as a subclass of DOMException with optional quota and requested properties, which are often null. Chrome 138 and later throw the subclass; other engines still throw a plain DOMException named QuotaExceededError. Checking error.name === "QuotaExceededError" works both in engines that throw the subclass and in those that throw a plain DOMException. Treat the error as a signal to shed optional data, not as a crash:

safe-put.js
const PURGEABLE_PREFIXES = ["acme-images-", "acme-api-"]; // never the precache

async function purgeOptionalCaches() {
  const names = await caches.keys();
  await Promise.all(
    names
      .filter((name) => PURGEABLE_PREFIXES.some((prefix) => name.startsWith(prefix)))
      .map((name) => caches.delete(name)),
  );
}

/** Store a response; on quota failure, drop optional caches and skip this write. */
export async function safePut(cacheName, request, response) {
  try {
    const cache = await caches.open(cacheName);
    await cache.put(request, response);
    return true;
  } catch (error) {
    if (error?.name !== "QuotaExceededError") throw error;
    await purgeOptionalCaches();
    // The response body was consumed by the failed put(); a retry would need a clone
    // made up front, which doubles memory for large bodies. Skipping is usually fine.
    return false;
  }
}

The same idea, deleting a runtime cache wholesale when a quota error occurs, is what Workbox's purgeOnQuotaError option does. Quota numbers, persistence and eviction are covered in Storage Quotas & Persistence.

Performance characteristics

The Cache Storage API is fast enough that it is rarely the bottleneck, but it is not free, and a few patterns make it much slower than it needs to be. No meaningful cross-browser latency numbers are published, so measure on your target devices.

Every call is asynchronous storage I/O

In multi-process browsers, Cache Storage lives outside the renderer process. Chromium, for example, implements it in the browser-side storage stack. Every method call is therefore an inter-process round trip plus a database or disk operation. Latency is dominated by the number of calls, not their size, which suggests the following rules:

  • Name the cache you query. caches.match() without cacheName runs one query per cache, in creation order, until it finds a hit. With ten caches, a miss costs ten queries.
  • Batch writes. addAll() commits many entries in one atomic job. Parallel put() calls with Promise.all() also beat sequential awaits.
  • Avoid fuzzy matching on large caches. ignoreSearch can force a scan instead of an indexed lookup.
  • Avoid keys() and matchAll() on the hot path. Both materialize an object for every matching entry. They are fine in activate or in a maintenance task, but not in every fetch.

Memoizing Cache handles

caches.open() is itself a round trip. Memoizing the promise per worker instance saves one per request:

sw.js
const cacheHandles = new Map();
const openCache = (name) => {
  if (!cacheHandles.has(name)) {
    const pending = caches.open(name);
    // Never memoize a failure (for example a QuotaExceededError): the next call retries.
    pending.catch(() => cacheHandles.delete(name));
    cacheHandles.set(name, pending);
  }
  return cacheHandles.get(name);
};

// Invalidate whenever this worker deletes caches, or writes go to an orphaned list.
async function deleteCache(name) {
  cacheHandles.delete(name);
  return caches.delete(name);
}

The invalidation matters because of the orphaned-handle behavior described in the data model section. The service worker's global scope is discarded whenever the worker is stopped, so the map never outlives one worker run.

Keep cache writes off the critical path

Return the network response to the page first, and let the write finish under event.waitUntil(). Awaiting put() before respondWith() resolves adds the whole body download and disk write to the page's load time. The same applies to metadata bookkeeping such as recordStored() or expire().

Service worker startup often dominates

A cache hit still requires a running service worker to execute your fetch handler. When the worker is stopped, which happens routinely after idle periods, starting it can cost more than the lookup. Two platform features address this. Navigation Preload parallelizes the network request with worker startup. The Static Routing API (Chrome 123, Safari 27) goes further: routes registered with event.addRoutes() during install can be served straight from Cache Storage without starting the worker.

sw.js
self.addEventListener("install", (event) => {
  if (!event.addRoutes) return; // Firefox and older engines: the fetch handler still works
  event.waitUntil(
    event.addRoutes([
      {
        condition: { urlPattern: new URLPattern({ pathname: "/assets/*" }) },
        source: { cacheName: "acme-static-2026-09-25" }, // a miss goes to the network
      },
    ]),
  );
});

Measuring cache performance

Inside the worker, performance.now() around match() measures the lookup itself. From the page, the Resource Timing entry of a service-worker-served resource exposes workerStart (worker startup included), and responseStart - fetchStart shows the whole service worker path. With static routing, Chrome 140 and later and Safari 27 add workerRouterEvaluationStart and workerCacheLookupStart to Resource Timing. See Measuring Performance.

sw-timing.js
async function timedMatch(cache, request) {
  const start = performance.now();
  const response = await cache.match(request);
  const ms = performance.now() - start;
  // Aggregate and report in batches; one analytics beacon per request is its own overhead.
  self.__cacheTimings ??= [];
  self.__cacheTimings.push({ hit: Boolean(response), ms });
  return response;
}

Cross-browser quirks and differences

The algorithms above are specified precisely and interoperable across engines. The differences are in the surrounding policy:

Area Chromium Firefox Safari / WebKit
Opaque response quota padding Pseudo-random 0 to ~14 MiB per response (~7 MiB average) Padded to hide the real size (since Firefox 57) Treat opaque responses as expensive and avoid them
estimate().usageDetails ✅ ❌ ❌ (estimate() itself since Safari 17)
Private browsing Works, with a reduced quota, and is cleared at session end Historically unavailable. Firefox 140 enabled service workers in private windows, built on the encrypted storage it already used for IndexedDB and the Cache API there (the change was planned for 139 and deferred one release) Works and is ephemeral
Automatic deletion of inactive sites Pressure-based LRU only Pressure-based LRU only Also deletes all script-writable storage, including Cache Storage, after 7 days of Safari use without interaction. Home Screen web apps are exempt
Static routing with source: "cache" ✅ Chrome 123 ❌ ✅ Safari 27
Storage partitioning of third-party iframes ✅ Chrome 115 ✅ ✅
Serving media from cache Supports Range from a stored 200 only if you build 206 responses Same Same, and media playback is particularly dependent on correct 206 handling

Utility code for production caches

Migrating entries between cache versions

Precaching into a new versioned cache on every deploy re-downloads everything, even files that did not change. If your build emits a manifest of URLs with content hashes, the new worker can copy unchanged entries from the previous cache and fetch only what changed. The new cache is filled completely before the worker installs, so the switch stays atomic at the cache-name level.

sw.js
// Generated at build time: [{ url, revision }]
import manifest from "./precache-manifest.js";

const PREFIX = "acme-static-";
const VERSION = "2026-09-25.2";
const CURRENT = `${PREFIX}${VERSION}`;
const MANIFEST_KEY = "/__precache-manifest.json"; // synthetic URL, never requested from the network

async function readManifest(cache) {
  const stored = await cache.match(MANIFEST_KEY);
  return stored ? new Map((await stored.json()).map((e) => [e.url, e.revision])) : new Map();
}

async function install() {
  // The most recently created previous version is the migration source.
  const previousName = (await caches.keys())
    .filter((name) => name.startsWith(PREFIX) && name !== CURRENT)
    .at(-1);
  const previous = previousName ? await caches.open(previousName) : null;
  const previousRevisions = previous ? await readManifest(previous) : new Map();

  const cache = await caches.open(CURRENT);
  try {
    await Promise.all(
      manifest.map(async ({ url, revision }) => {
        if (previous && previousRevisions.get(url) === revision) {
          const reused = await previous.match(url);
          if (reused) return cache.put(url, reused); // unchanged: copy, no download
        }
        const response = await fetch(new Request(url, { cache: "reload" }));
        if (!response.ok) throw new Error(`Precache failed for ${url}: ${response.status}`);
        return cache.put(url, response);
      }),
    );
    await cache.put(
      MANIFEST_KEY,
      new Response(JSON.stringify(manifest), { headers: { "Content-Type": "application/json" } }),
    );
  } catch (error) {
    await caches.delete(CURRENT); // no half-filled cache survives a failed install
    throw error;
  }
}

self.addEventListener("install", (event) => event.waitUntil(install()));

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const names = await caches.keys();
      await Promise.all(
        names.filter((n) => n.startsWith(PREFIX) && n !== CURRENT).map((n) => caches.delete(n)),
      );
    })(),
  );
});

The old cache is deleted only in activate, after the new worker has taken over, so pages still controlled by the old worker keep working during the update. Update timing is covered in Updating Service Workers.

Normalizing cache keys

To ignore tracking parameters without the bluntness of ignoreSearch, normalize URLs yourself and use the normalized string as the key for both reads and writes:

normalize-key.js
const IGNORED = [/^utm_/, /^fbclid$/, /^gclid$/, /^mc_(cid|eid)$/];

/** Stable cache key: fragment removed, tracking params removed, remaining params sorted. */
export function cacheKeyFor(input) {
  const url = new URL(typeof input === "string" ? input : input.url, self.location.href);
  url.hash = "";
  for (const name of new Set(url.searchParams.keys())) {
    if (IGNORED.some((pattern) => pattern.test(name))) url.searchParams.delete(name);
  }
  url.searchParams.sort(); // "?b=2&a=1" and "?a=1&b=2" become one key
  return url.href;
}

// Reads and writes must both use the normalized key:
//   await cache.match(cacheKeyFor(request), { ignoreVary: true });
//   await cache.put(cacheKeyFor(request), response);

A string key becomes a Request without headers, so pass ignoreVary: true on reads if the server sends Vary on headers such as Accept.

Serving Range requests from a cached response

Media elements request byte ranges, and put() refuses to store 206 responses. Cache the complete 200 response (for example with add() during a "download for offline" action, since runtime playback only ever fetches partial content), then synthesize 206 responses from it:

range-response.js
/**
 * Build a 206 (or 416) response for a single-range Range request from a full cached 200.
 * Multi-range requests get the full 200, which RFC 9110 permits.
 */
export async function rangeResponse(request, cached) {
  const header = request.headers.get("Range");
  if (!header || cached.status !== 200) return cached;

  const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
  if (!match) return cached; // multi-range or unknown unit: ignore the header

  const blob = await cached.blob(); // implementations can back this with the stored entry
  const size = blob.size;
  let start;
  let end;
  if (match[1] === "") {
    // Suffix range: the last N bytes.
    const suffix = Number(match[2]);
    if (!suffix) return unsatisfiable(size);
    start = Math.max(size - suffix, 0);
    end = size - 1;
  } else {
    start = Number(match[1]);
    end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
  }
  if (start >= size || start > end) return unsatisfiable(size);

  const headers = new Headers(cached.headers);
  headers.set("Content-Range", `bytes ${start}-${end}/${size}`);
  headers.set("Content-Length", String(end - start + 1));
  headers.delete("Content-Encoding"); // the stored body is already decoded
  return new Response(blob.slice(start, end + 1), {
    status: 206,
    statusText: "Partial Content",
    headers,
  });
}

function unsatisfiable(size) {
  return new Response(null, {
    status: 416,
    statusText: "Range Not Satisfiable",
    headers: { "Content-Range": `bytes */${size}` },
  });
}

In the fetch handler, call rangeResponse(event.request, await cache.match(event.request)) for audio and video destinations. Workbox packages the same logic as its range requests plugin. See Advanced Workbox.

Caching POST responses under a synthetic key

put() only accepts GET requests. For read-only POST queries, such as GraphQL queries or search APIs that take a JSON body, derive a deterministic GET key from the URL and a hash of the body. Never do this for mutations.

post-cache-key.js
/** Deterministic GET Request that stands in for a POST in Cache Storage. */
export async function syntheticKeyFor(request) {
  const body = await request.clone().arrayBuffer(); // clone: the original may still be sent
  const digest = await crypto.subtle.digest("SHA-256", body);
  const hex = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
  const url = new URL(request.url);
  url.searchParams.set("__body_sha256", hex); // namespaced so it cannot collide with real params
  return new Request(url.href, { method: "GET" });
}

// Network-first for GraphQL queries:
self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "POST" || !request.url.endsWith("/graphql")) return;
  event.respondWith(
    (async () => {
      const key = await syntheticKeyFor(request);
      const cache = await caches.open("acme-graphql-v1");
      try {
        const response = await fetch(request);
        if (response.ok) event.waitUntil(cache.put(key, response.clone()));
        return response;
      } catch (error) {
        const cached = await cache.match(key);
        if (cached) return cached;
        throw error; // offline and never seen this query: let the page handle the failure
      }
    })(),
  );
});

The synthetic URL is never requested from the network. It only has to be stable and unique per query. JSON bodies must be serialized deterministically, with the same key order, for identical queries to share an entry.

Saving content for offline from a page

Window code can write to Cache Storage directly, which is the simplest way to build "save for offline" buttons. The service worker only needs to look in that cache.

save-offline.js
const SAVED = "acme-saved-articles-v1";

export async function saveForOffline(articleUrl, assetUrls = []) {
  const cache = await caches.open(SAVED);
  // Called from a controlled page, addAll() requests pass through the service worker's
  // fetch handler. Make sure that handler does not answer them from SAVED itself.
  await cache.addAll([articleUrl, ...assetUrls]);
  await navigator.storage.persist?.(); // saved content is exactly what persistence is for
}

export async function removeFromOffline(articleUrl) {
  const cache = await caches.open(SAVED);
  return cache.delete(articleUrl, { ignoreSearch: true, ignoreVary: true });
}

export async function listSaved() {
  if (!(await caches.has(SAVED))) return [];
  const cache = await caches.open(SAVED);
  return (await cache.keys()).map((request) => request.url);
}

The UX for this, including communicating saved state and space used, is covered in Offline UX & Fallbacks.

Browser support

Support data as of September 2026. Check MDN's Cache and CacheStorage tables or caniuse for live data.

Feature Chrome / Edge Firefox Safari
Cache and CacheStorage in service workers ✅ 40 ✅ 44 ✅ 11.1
Cache and CacheStorage in windows and other workers ✅ 43 ✅ 41 (workers 44) ✅ 11.1
cache.addAll() ✅ 46 ✅ 41 ✅ 11.1
cache.matchAll() ✅ 47 ✅ 41 ✅ 11.1
caches.match() with all options ✅ 54 ✅ 41 ✅ 11.1
Secure-context-only caches ✅ 65 ✅ 44 ✅ 11.1
navigator.storage.estimate() ✅ 61 ✅ 57 ✅ 17
estimate().usageDetails ✅ 61 ❌ ❌
Web Locks (for coordinating writers) ✅ 69 ✅ 96 ✅ 15.4
Static routing (addRoutes, cache source) ✅ 123 ❌ ✅ 27
Module service workers (for import in sw.js) ✅ 91 ✅ 147 ✅ 15

Safari versions are for macOS. Safari on iOS and iPadOS gained Cache Storage and service workers in iOS 11.3; every other row applies to iOS at the same version number. Chrome 40–42 exposed Cache Storage only inside service workers, and before Chrome 54 caches.match() supported only the ignoreSearch and cacheName options. Edge versions before 79 (EdgeHTML) supported the API from Edge 16.

Common pitfalls

  • Expecting entries to expire. They never do. Every runtime cache needs a maxEntries, age or byte policy, and every deploy needs cleanup of old caches.
  • Using a Response twice. put() consumes the body. Call clone() before either consumer reads, or you get "Response body is already used."
  • Caching error responses. put() stores 404s, 500s and opaque failures. Check response.ok (or use isStorable()) before writing.
  • Awaiting writes inside respondWith(). It delays the page. Use event.waitUntil().
  • caches.match() returning an old version. It searches caches oldest-first. Delete old caches in activate, or pass cacheName.
  • Precaching stale files. addAll() goes through the HTTP cache. Use hashed URLs or cache: "reload".
  • Serving redirected responses to navigations. Clean them with new Response(body, init) first.
  • Assuming cookies or Vary: Cookie separate users. They do not. Clear personalized caches on logout, or Clear-Site-Data: "storage".
  • Opaque responses in cache-first routes. They hide errors and cost quota. Use CORS.
  • Duplicate URLs in addAll(). An InvalidStateError fails the whole install. De-duplicate after removing fragments.
  • Writing to a memoized handle of a deleted cache. The writes go to an orphaned list and are lost.

Debugging Cache Storage

In Chromium DevTools, open Application → Cache storage. Each cache lists its entries with URL, response type, Content-Type, Content-Length and "Time Cached". Selecting an entry shows its headers and a preview, and you can delete entries or refresh the view (it does not live-update). The Storage view shows usage by type and has Clear site data, which removes Cache Storage along with everything else. In the Network panel, "(ServiceWorker)" in the Size column marks responses supplied by the worker, and requests the worker made itself are marked with a gear icon. In Firefox, the Storage Inspector lists Cache Storage per origin.

Useful console snippets, which work in any engine:

console snippets
// Find every cache that holds a URL (all variants).
for (const name of await caches.keys()) {
  const hits = await (await caches.open(name)).matchAll("/app.js", { ignoreVary: true });
  if (hits.length) console.log(name, hits.map((r) => [r.status, r.type, r.headers.get("Date")]));
}

// Inspect what a request would match, and why not.
const c = await caches.open("acme-pages-v4");
console.log(await c.match("/about"), await c.match("/about", { ignoreSearch: true, ignoreVary: true }));

// Nuke all Cache Storage for this origin (development only).
await Promise.all((await caches.keys()).map((n) => caches.delete(n)));

When a lookup unexpectedly misses, check in this order: an exact URL mismatch (trailing slash, query order, index.html versus /), a non-GET method, a Vary header on the stored response, and whether the entry lives in a different cache than the one you queried. More workflows are covered in Browser DevTools.

Further reading

On this site

External references