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(aCacheStorage) manages an ordered set of named caches. EachCacheholds an ordered list ofRequest/Responsepairs. Every method is promise-based, and nothing in the API is synchronous.- Matching compares the URL without its fragment, the method (only
GETmatches unlessignoreMethod), and the request headers named in the cached response'sVary. Cookies, credentials, request mode and destination are not part of the key. add()/addAll()fetch, validate and store atomically. They reject with aTypeErrorif any response is a network error, is not 2xx, is a206, or carriesVary: *.put()stores almost anything, including 404s and opaque responses.- Stored
Cache-Control,ExpiresandETagheaders 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(), avoidkeys()on very large caches, and never blockrespondWith()on a cache write. Useevent.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.comsees 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.exampleembedded onhttps://news.examplegets 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
activateevent 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:
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:
- 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, socache.keys()andcache.matchAll()return entries in insertion order. Re-putting an existing key removes the old entry and appends the new one at the end. That makeskeys()a free FIFO queue, as shown in Implementing LRU and size limits. Cacheobjects are handles. ManyCacheobjects, 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.- Deleting a cache orphans live handles. The spec notes that after
caches.delete(name), existingCache,RequestandResponseobjects "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: anactivatehandler 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.
/** 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.
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 runsCache.match()on it. If no cache has that name, it resolves withundefined. Unlikeopen(), it does not create one. - Without
cacheName, it chains oneCache.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¶
[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
requestis aRequestwhose method is notGETandignoreMethodisfalse, the result isundefinedimmediately, without touching storage. This is why a naive cache-first handler silently never servesPOSTrequests from cache. - Every call returns a new
Responseobject whoseHeadershave the"immutable"guard. You can read the body once per object. To use a hit twice, for example to respond and also inspect it, callclone()before consuming either copy. To change a header, construct a newResponse.
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:
- If
ignoreMethodisfalseand the cached request's method is notGET, it is not a match. (The public methods have already returned early for non-GETqueries.) - Take both URLs. If
ignoreSearchistrue, set both URLs' query to the empty string. - If the URLs differ when compared with fragments excluded, it is not a match.
- If the cached response is null,
ignoreVaryistrue, or the cached response has noVaryheader, it is a match. - Otherwise, for each field name in the cached response's
Varyheader: 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. - 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 keeppage". 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.
/** 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:
- If
requestis a string, construct aRequestfrom it. A constructor failure rejects the promise. - Reject with a
TypeErrorif the URL scheme is nothttporhttps(for examplechrome-extension:,data:orblob:), or if the method is notGET. - Reject with a
TypeErrorif the response status is 206. Partial content is never stored. - Reject with a
TypeErrorif the response'sVaryheader contains*. - Reject with a
TypeErrorif the response body is disturbed or locked, which is the "Response body is already used" error. - Clone the response, then read the entire body of the one you passed in. Your
Responseis consumed after this call. - 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
QuotaExceededErrorand 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:
/**
* 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():
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;
})(),
);
});
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 whileput()reads quickly can hold the whole body in memory. For very large media, consider caching from a separate fetch or streaming to OPFS instead.- Calling
waitUntil()asynchronously is allowed here because the promise passed torespondWith()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:
- Pre-validation. For every
Requestobject in the list, reject with aTypeErrorif its scheme is nothttp/httpsor its method is notGET. This happens before any network activity. - Request construction. Each item is run through the
Requestconstructor. Strings therefore become requests withmode: "cors",credentials: "same-origin"andcache: "default". A bad scheme aborts all fetches started so far and rejects. - 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 ownfetchhandler, which might answer it from the cache you are trying to fill. - Parallel fetches. When each response arrives, the promise for that request rejects with a
TypeErrorif the response is a network error, its status is not in 200–299, or it is 206. If it carriesVary: *, it rejects and aborts all the other fetches. - 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. - One atomic batch. When all responses are complete, every
putruns 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 anInvalidStateError. If storage fails for quota reasons, it throws aQuotaExceededError. 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 ininstall: a rejected promise passed toevent.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 (withAbortSignal.timeout()), validate, andput()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. Passnew 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-corsrequest produces status 0, which is not in 200–299, soadd()rejects. Usefetch()plusput(). - Duplicates are errors.
addAll(["/", "/#top"])rejects withInvalidStateError. De-duplicate your manifest after removing fragments.
/**
* 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):
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:
/** 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:
- 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.
- 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.
- Reads can fail. As described under
matchAll(), the Cross-Origin-Resource-Policy check can makematch()reject with aTypeErrorfor 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.
// 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 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.
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.
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.
/**
* 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:
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:
/**
* 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:
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.
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.
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:
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()withoutcacheNameruns 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. Parallelput()calls withPromise.all()also beat sequential awaits. - Avoid fuzzy matching on large caches.
ignoreSearchcan force a scan instead of an indexed lookup. - Avoid
keys()andmatchAll()on the hot path. Both materialize an object for every matching entry. They are fine inactivateor in a maintenance task, but not in everyfetch.
Memoizing Cache handles¶
caches.open() is itself a round trip. Memoizing the promise per worker instance saves one per request:
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.
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.
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.
// 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:
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:
/**
* 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.
/** 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.
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
Responsetwice.put()consumes the body. Callclone()before either consumer reads, or you get "Response body is already used." - Caching error responses.
put()stores 404s, 500s and opaque failures. Checkresponse.ok(or useisStorable()) before writing. - Awaiting writes inside
respondWith(). It delays the page. Useevent.waitUntil(). caches.match()returning an old version. It searches caches oldest-first. Delete old caches inactivate, or passcacheName.- Precaching stale files.
addAll()goes through the HTTP cache. Use hashed URLs orcache: "reload". - Serving redirected responses to navigations. Clean them with
new Response(body, init)first. - Assuming cookies or
Vary: Cookieseparate users. They do not. Clear personalized caches on logout, orClear-Site-Data: "storage". - Opaque responses in cache-first routes. They hide errors and cost quota. Use CORS.
- Duplicate URLs in
addAll(). AnInvalidStateErrorfails 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:
// 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
- Caching & Offline overview: how Cache Storage relates to every other storage layer
- Caching Strategies: cache-first, network-first and stale-while-revalidate built on this API
- Precaching & Runtime Caching: manifests, revisioning and install-time population
- HTTP Caching & Service Workers: how the HTTP cache feeds and differs from Cache Storage
- Storage Quotas & Persistence: quotas, eviction and
persist() - IndexedDB: the metadata store behind expiration and LRU
- Handling Fetch Events:
respondWith(),waitUntil()and routing - Workbox Fundamentals: a library implementation of expiration, precaching and range requests
External references
- Service Workers specification: Caches (W3C)
- Fetch Standard (WHATWG): requests, responses, CORS filtering and header layers
- Cache and CacheStorage (MDN)
- Cache API: a quick guide (web.dev)
- Understanding storage quota (Chrome, opaque response padding)
- Caching resources during runtime (Chrome)
- Serving cached audio and video (Chrome)
- RFC 9111: HTTP Caching and RFC 9110: HTTP Semantics