Cache Storage
A script-controlled store of Request/Response pairs. A different layer from the browser's HTTP cache, with different rules, and the reason a service worker can answer a request with no network at all.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How is Cache Storage different from the HTTP cache the browser already has, and what do I gain by taking that decision away from the browser?
Someone taps the app icon in a lift with no signal and expects the interface to appear rather than a dinosaur — and when they are online again, expects it to be the current version rather than the one from last spring.
The browser already caches responses. Cache Storage must be the same thing with a JavaScript API, so put everything in it with cache.addAll at install and the app will be fast and work offline.
It is not the same thing. The HTTP cache is the browser's, governed by Cache-Control, ETag and heuristics you influence but do not control. Cache Storage is yours: nothing expires, nothing is revalidated, and nothing is evicted except by you or by quota (Browser HTTP Caching).
- It is not the same thing. The HTTP cache is the browser's, governed by
Cache-Control,ETagand heuristics you influence but do not control. Cache Storage is yours: nothing expires, nothing is revalidated, and nothing is evicted except by you or by quota (Browser HTTP Caching). - "Nothing expires" is the part that bites. An entry cached at install is served forever, so a bug fix shipped in that file never reaches users who already have the old one (Content-Hashed Assets).
addAllis atomic: if one URL in the list 404s or fails, the whole install fails and the service worker never activates — usually silently, on some users only (The Service Worker Lifecycle).- Caching everything at install pulls a large payload on first visit, competing with the resources the first render actually needs (The Critical Rendering Path).
- Cached responses are opaque when they come from a cross-origin request without CORS, which means you cannot read their status and they count against quota at a padded size (CORS).
What is actually happening
In the browser, not in the framework.
cachesis a named-cache registry on the origin. Each cache is a map fromRequesttoResponse, so entries carry method, URL, headers, status and body — not just bytes (The Life of a Fetch).- The API is promise-based and available in both pages and service workers, which is what lets a worker answer a
fetchevent from disk while the page is offline (Intercepting Fetch). - Matching is by URL by default.
ignoreSearch,ignoreMethodandignoreVaryare options onmatch, which means the store honoursVaryonly if you tell it to — a place where its rules diverge from the HTTP cache's. - A
Responsebody is a stream and can be consumed once.cache.put(request, response.clone())exists because you almost always need the same response twice: once to return to the page, once to store. - Cache Storage draws from the same origin quota pool as IndexedDB and is subject to the same eviction (Storage Security and Durability).
- Nothing in the store is automatic. There is no expiry, no revalidation, no size limit per cache and no cleanup — every one of those is a policy you write (Caching Strategies).
What this makes the browser do
And which of it is avoidable.
- A
matchis a key lookup plus, optionally, aVaryheader comparison. Cheap, but it is still disk I/O off the main thread. - A
putwrites the response body to disk, so caching a large asset is real write work competing with the network fetch that produced it. - A service worker intercepting
fetchadds a step to every request in its scope, including ones it passes straight through — a fixed cost paid for the ability to answer any of them (Intercepting Fetch). - Opaque cross-origin responses are stored padded, so quota accounting for them is deliberately pessimistic and the store fills faster than the byte count suggests.
- Deleting a cache is a bulk disk operation the browser does in the background; a version-and-delete strategy is cheap for you and not free for the device.
Two caches, one request
A request in a page controlled by a service worker passes through two independent caches. The service worker sees it first and may answer from Cache Storage without touching the network. If it does not, the request continues into the browser's ordinary machinery, where the HTTP cache may answer it from a stored response, and only then does anything reach the network (Browser HTTP Caching).
They do not know about each other. A response served from Cache Storage never consults Cache-Control; a response revalidated by the HTTP cache never updates Cache Storage. Holding both in your head is what makes "it is cached, but which cache" a question you can answer.
Where the rules differ
The comparison worth memorising is not "which is faster" — they are both disk — but who decides. Everything the HTTP cache does automatically becomes a decision you make explicitly, and every decision you make explicitly is one the browser will not second-guess.
The last row is the one that causes production incidents. An HTTP cache entry eventually revalidates because the response said it should; a Cache Storage entry does not, because nothing in it is a directive to anyone.
| HTTP cache | Cache Storage | |
|---|---|---|
| Who owns it | The browser | Your code, in a page or a service worker |
| What it stores | Responses keyed by request, per its own rules | Request/Response pairs you put there by name |
| Governed by | Cache-Control, ETag, Vary, heuristics | Nothing — you match, you put, you delete |
| Expiry | Automatic, from response directives | None. An entry lives until deleted or evicted |
| Revalidation | Conditional requests on your behalf | Only if you write the code to do it |
| Works offline | Sometimes, for still-fresh entries | Yes — that is the point of it |
| Inspectable from script | No | Yes — enumerate, read and delete every entry |
| Invalidation strategy | Change the URL, or set shorter directives | Version the cache name and delete the old one |
A strategy per resource class
The code below is a compact version of what most production service workers do: precache a small shell, serve content-hashed assets cache-first because their URL guarantees freshness, and serve the HTML entry point network-first so a deploy actually reaches people. It is deliberately short on cleverness (Intercepting Fetch).
The activate handler is the part to copy. Versioning the cache name turns invalidation into a delete, which is the only invalidation strategy in this store that is hard to get wrong (Cache Invalidation, Stampedes and Hot Keys in Databases).
1const VERSION = 'v2026-08-26'2const SHELL = `shell-${VERSION}`3const ASSETS = `assets-${VERSION}`4 5self.addEventListener('install', (e: any) => {6 // Small and tolerant: one 404 in addAll aborts the whole install.7 e.waitUntil(caches.open(SHELL).then((c) => c.addAll(['/offline.html'])))8})9 10self.addEventListener('activate', (e: any) => {11 // Versioned names make invalidation a delete.12 e.waitUntil(13 caches.keys().then((names) =>14 Promise.all(names.filter((n) => !n.endsWith(VERSION)).map((n) => caches.delete(n))),15 ),16 )17})18 19self.addEventListener('fetch', (e: any) => {20 const req: Request = e.request21 if (req.method !== 'GET') return // never cache mutations22 const url = new URL(req.url)23 if (url.origin !== self.location.origin) return // leave third parties alone24 25 // Content-hashed assets: the URL changes when the bytes do, so cache-first is safe.26 if (/\.[0-9a-f]{8,}\.(js|css|woff2)$/.test(url.pathname)) {27 e.respondWith(28 caches.open(ASSETS).then(async (cache) => {29 const hit = await cache.match(req)30 if (hit) return hit31 const res = await fetch(req)32 if (res.ok) await cache.put(req, res.clone()) // clone: the body reads once33 return res34 }),35 )36 return37 }38 39 // The HTML entry point: network-first, or the deploy never reaches anyone.40 if (req.mode === 'navigate') {41 e.respondWith(42 fetch(req).catch(async () => (await caches.match('/offline.html'))!),43 )44 }45})Three decisions carry the weight: mutations are never cached, the hashed-asset test is what makes cache-first correct rather than reckless, and navigations are network-first so a stale HTML file cannot pin users to an old build. The res.clone() is not defensive style — a response body is a stream that can only be read once.
How to build it
Most important first.
- Decide per resource class, not per application. The app shell, hashed assets, fonts, avatars and API responses have different freshness requirements and belong to different strategies (Caching Strategies).
- Version the cache name and delete the old ones on
activate. That is the cheapest correct invalidation available: a new deploy means a new cache, and the previous one is removed in one step (The Service Worker Lifecycle). - Cache content-hashed assets aggressively and immutably, because their URL changes when their content does — the entire problem disappears (Content-Hashed Assets).
- Never cache the HTML entry point without a revalidation strategy. It is the file that tells the browser which hashed assets to load, so a stale copy pins users to an old build indefinitely.
- Keep
installsmall: cache what the first meaningful render needs and let the rest arrive on demand, so installation does not compete with the visit that triggered it. - Write the eviction policy yourself — cap entries per cache, drop the oldest, and prune on
activate. The store will not do it for you and quota failures are not a graceful degradation. - Handle a
matchmiss with a real fallback: an offline page, cached data with an explicit staleness indicator, or an honest error. A promise that resolves toundefinedis not an offline experience (Offline UX).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- An offline experience built on this store keeps a person able to finish what they started. That is an accessibility outcome as much as a performance one, especially for users whose input method makes redoing work expensive (Offline UX).
- Serving cached content must be announced when it changes what the user is looking at. Silently swapping stale content for fresh content moves the reading position of a screen-magnifier user and can move a focus target mid-action (Live Regions and Announcement).
- The offline fallback page is a real page and owes the same contract as any other: a heading structure, keyboard operability, and a focusable, announced explanation of what happened (Focus Management).
- Losing cached state silently — an eviction, a failed install — should not present as an empty application. Say what is missing and offer a way to recover it (Loading, Error, Empty — The States You Did Not Render).
What can go wrong
- A stale entry served forever because nothing revalidates it and the deploy did not change the cache name. Users report "the fix did not ship" and every server-side check says it did (Release Health).
addAllfailing on one URL and aborting the install, so a subset of users never get a service worker at all and the failure is invisible in aggregate.- The HTML entry point cached cache-first, pinning clients to an old build's asset URLs — the classic way a well-intentioned offline story becomes a stuck deploy (Deploying a Frontend).
- Quota exceeded while writing, so caching silently stops working and the offline experience degrades without any error the user sees.
- An opaque response cached and then served, with a status your code cannot read, so a cached 404 becomes an image that will never load again.
- The mitigation failing: a cache-busting query parameter added to a cached request, which by default
matchcompares — so the "bust" creates a second entry rather than replacing the first.
- A new service worker installing while the current one is serving requests: two versions of your caching logic are live at once, and which one answers a given request depends on which worker controls that client (The Service Worker Lifecycle).
- A stale-while-revalidate response and its background refresh: the UI renders the cached copy, then the fresh copy arrives, and the update must be applied without moving what the user is reading (Stale-While-Revalidate).
- A cache write racing an
activatecleanup that deletes the cache being written to, producing a rejectedputthat looks like a quota failure.
- The cache is per origin and readable by every script on that origin, including the service worker. Cached API responses are cached user data, with the same exposure as anything else in browser storage (Storage Security and Durability).
- A service worker that can write this cache can also answer future requests from it, which makes a compromised worker a persistent position on the origin — one that survives reloads (The Browser Security Model).
- Never cache responses containing another user's data on a shared device, and clear the cache on sign-out. Authorization is decided by the server per request; a cached response has already lost that context (Authorization-Aware UI).
- The browser enforces that a service worker may only be registered from, and control, its own origin and scope, and only over a secure transport. That is the guarantee this whole layer rests on (The Same-Origin Policy).
- Opaque responses cannot be inspected, so you cannot verify what you cached — a reason to be conservative about caching cross-origin resources you do not control (Third-Party Scripts and the Supply Chain).
- "Cache Storage is the HTTP cache with an API." Different layer, different rules, different owner.
Cache-Controldoes not govern what you put here. - "Caching a response means it is fresh." It means it is stored. Freshness is a policy you implement, and by default there is none.
- "The service worker updates when I deploy." A new worker is fetched, installed and then waits; the page keeps using the old one until the old clients are gone or you take control explicitly (The Service Worker Lifecycle).
- "Offline-first means cache-first for everything." Cache-first for the HTML entry point is how a deploy stops reaching users (Caching Strategies).
- "I can read the cached response like any other." Not if it is opaque; a cross-origin response fetched without CORS has no readable status or headers (CORS).
- "Quota is large, so I do not need eviction." Quota is a browser policy against free disk, and when you hit it your writes fail rather than making room (Storage Security and Durability).
Measuring it, and what changes in the field
- The Application panel lists Cache Storage by cache name with every entry, which is how you confirm that the old versioned cache was actually deleted (A Mental Model of the Devtools).
- The Network panel marks responses served by the service worker distinctly from those served by the HTTP cache and from the network — three different sources that a total byte count cannot distinguish (Debugging the Network).
- A storage-estimate call reports usage against quota for the origin, and it is where an unbounded cache becomes visible before users hit the limit.
- In the field, report install and activate failures, and the ratio of cache hits to network fallbacks; a service worker that never installs looks identical to one that installed and is doing nothing (Frontend Error Tracking).
- Offline, this store is the entire application: whatever is not in it does not exist (Offline UX).
- On a slow network, a cache hit is the difference between an instant render and a spinner, which is why this is a performance tool and not only an offline one.
- On a device short of disk, eviction removes caches wholesale and the offline story fails on exactly the devices most likely to be offline.
- On a repeat visit after a deploy, the interaction between the cached HTML and the new hashed assets decides whether the user gets the new build at all (Content-Hashed Assets).
- In a long-lived tab, the page is controlled by whichever service worker version was active when it loaded, not the newest one (The Service Worker Lifecycle).
- Taking control of caching means the browser's heuristics stop helping you. Every expiry, revalidation and eviction rule the HTTP cache implemented for free is now code you own and can get wrong.
- Cache-first is the fastest strategy and the one most likely to serve something stale; network-first is the freshest and offers nothing when the network is slow rather than absent (Stale-While-Revalidate).
- Precaching at install makes the second visit excellent at the cost of bandwidth on the first, on a connection you cannot see.
- A service worker is a deployment artefact with its own lifecycle, so shipping one means every future deploy has a second update path to reason about (Deploying a Frontend).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe Cache Storage API, its match semantics and its relationship to the service worker
fetchevent are specified and consistent across Blink, Gecko and WebKit; the strategies built on it are portable. - BROWSER-SPECIFICQuota, opaque-response padding and eviction order are implementation policy: Safari applies a smaller effective budget and has historically removed script-written storage after a period of inactivity, where Chrome and Firefox evict primarily under disk pressure.
- SPEC-EVOLVINGStorage buckets, persistence and the partitioning of storage by top-level site are still changing, so how much of this survives for a site loaded inside a third-party context should be verified against current browser documentation rather than assumed.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — a service worker cache is a replica of the origin's content held on a device you cannot reach, and "how does a replica learn it is stale" is that domain's question.