Browser HTTP Caching
What the browser does before it makes a request at all: freshness, no-cache versus no-store, validators, revalidation, and immutable.
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.
When does the browser skip the network entirely, when does it ask "has this changed?", and what decides which?
Somebody comes back to a page they visited yesterday. The fastest possible response is the one nobody has to send.
Caching is a server concern. Set some headers, or let the framework set them, and the browser will do something sensible.
"Something sensible" in the absence of headers is a heuristic. The browser guesses a freshness lifetime from the last modification date, and your asset is cached for an interval nobody chose.
- "Something sensible" in the absence of headers is a heuristic. The browser guesses a freshness lifetime from the last modification date, and your asset is cached for an interval nobody chose.
- You ship a fix, and a portion of users keep running the old JavaScript for a day, because the HTML that names it was itself cached with a long lifetime.
- You add
no-cacheto stop that happening and discover it does not stop the response being stored — it stops it being *reused without asking*, which is a different and much more useful thing than the name suggests. - You add
no-storeinstead and every navigation now refetches everything, on every device, forever. - Your
ETags change on every deploy for files whose bytes did not change, because they are derived from something server-specific, so every conditional request returns a full body (Deploying a Frontend).
What is actually happening
In the browser, not in the framework.
- Before making a request, the browser looks in its HTTP cache. If a stored response for that URL is fresh, it is used with no network involvement at all — no request, no round trip, nothing to be slow.
- Freshness comes from
Cache-Control: max-age, orExpires, or — when neither is present — a heuristic based onLast-Modified. The heuristic is the reason "no headers" is not the same as "no caching". - If the stored response is stale, the browser may revalidate: send a conditional request carrying
If-None-Matchwith the storedETag, orIf-Modified-Sincewith the storedLast-Modified. A304 Not Modifiedmeans "reuse what you have" and carries no body. no-cachemeans *store it, but revalidate before every reuse*.no-storemeans *do not write it down anywhere*. They are routinely swapped, and the swap is expensive in both directions:no-storewhere you meantno-cacherefetches everything;no-cachewhere you meantno-storewrites a private response to disk.must-revalidateforbids serving a stale response even when the network is unavailable.immutablesays the opposite: while this response is fresh, do not revalidate it even if the user reloads.privatemarks a response as belonging to one user, so a shared cache must not store it;s-maxagesets a freshness lifetime that only shared caches use (CDN Delivery).- This cache is keyed by more than the URL. Modern browsers partition it by the top-level site, so the shared-CDN-cache effect that used to justify public asset hosts no longer exists.
What this makes the browser do
And which of it is avoidable.
- A cache lookup on every request, including matching against
Varyto pick the right stored variant. - Writing responses to memory and to disk, with eviction under storage pressure — a cached response is a hint about the future, never a guarantee.
- Conditional requests: still a full round trip, still latency, just without a body. Revalidation is cheaper than a download and much more expensive than a hit.
- Reconciling with any service worker in front of it, which sees the request first and may answer from a completely different cache (Intercepting Fetch).
- Maintaining the back/forward cache separately: a whole page kept alive in memory, restored without re-running anything, which is a different mechanism with different rules (History and Navigation).
What the browser does before it asks
The most important thing about HTTP caching is that its best outcome involves no network at all. Not a fast request — no request. Every discussion of cache headers is really a discussion about how often you reach step two below rather than step four.
Note that revalidation sits in the middle. It is genuinely useful — it avoids transferring a body — and it is not a cache hit. On a high-latency connection a page full of 304 responses is a page full of round trips, and it will feel like one.
- 1Look up the URL
Find a stored response for this URL, matching on any
Varyheaders the stored response declared.fails by A missing
Varyon a response that varies by encoding or language, so the wrong stored variant matches and is served. - 2Is it fresh?
Compare age against
max-age,Expires, or a heuristic derived fromLast-Modified. Fresh means reuse immediately, with no network at all.fails by No headers at all — the heuristic picks a lifetime nobody chose, and the asset is cached for an interval you did not decide on.
- 3Serve it
The response is used directly from memory or disk. This is the outcome everything else exists to reach.
fails by
immutableon a URL whose content can change, which pins users to a stale file for the whole freshness window. - 4Stale: revalidate
Send a conditional request with
If-None-MatchorIf-Modified-Since. A304means reuse the stored body.fails by A validator that changes even when the content does not — typically a server-derived
ETag— so every conditional request returns a full200. - 5No entry, or `no-store`
Make an ordinary request and transfer the whole body.
fails by
no-storeapplied broadly because it was mistaken forno-cache, which turns every navigation into a cold one.
Steps three and four are both "the cache worked". Only step three avoided the network.
The directives, precisely
Two of these are confused with each other constantly, and the confusion is understandable: no-cache sounds like it means what no-store means. It does not, and the difference is the difference between "ask me first" and "forget you ever saw this".
The last column is the one to read for each row: not what the directive says, but what a user actually experiences when it is wrong. That is the framing that makes the choice obvious in most real cases.
| Directive | What it means | Use it for | What going wrong feels like |
|---|---|---|---|
max-age=N | Fresh for N seconds; reuse with no network during that window | Any response whose staleness for that long is acceptable | Too long on an HTML document and users run last week's application with no way to correct it |
no-cache | Store it, but revalidate before every reuse | HTML documents, and anything that must always be current but is usually unchanged | Nothing dramatic — a round trip per load, often answered 304. The safe default for documents. |
no-store | Do not write this response down anywhere, at any layer | Authenticated pages and anything with personal data in the body | Every visit is a cold visit. Correct for sensitive responses, expensive everywhere else. |
must-revalidate | Once stale, do not serve it even if the network is unavailable | Responses where showing outdated data is worse than showing an error | An offline user sees a failure where they could have seen slightly old content |
immutable | While fresh, do not revalidate — not even on a reload | Content-addressed assets whose URL changes when their bytes do (Content-Hashed Assets) | Applied to a mutable URL, it removes your ability to correct the file at all |
private | A shared cache must not store this; the browser still may | Any per-user response that is still worth caching in the browser | Missing on an authenticated response, a shared cache serves one user's page to another |
s-maxage=N | Freshness for shared caches only, overriding max-age for them | Holding content at the edge longer than in the browser (CDN Delivery) | The edge and the browser disagree about how old a response may be, in ways that are hard to reproduce |
stale-while-revalidate=N | Serve stale immediately, refresh in the background | Responses where instant-but-slightly-old beats correct-but-late | The user acts on data that was already superseded (Stale-While-Revalidate) |
Validators, and the deploy that ignores them
A conditional request is a short conversation: the browser says "I have this version, is it still current?", and the server either says no with a body or yes with three lines and no body. The whole exchange depends on the validator being stable for stable content.
The failure that catches most teams is not in the browser at all. It is that ETag values are generated per server, or per build, or from a file identifier rather than from content — so an unchanged asset gets a new validator every deploy and every conditional request downloads the whole file again. Nothing errors. The cache appears to be working, and it is transferring everything.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
no-cache used where no-store was meant | A page with personal data is recoverable from the disk cache | no-cache permits storage; it only forbids reuse without revalidation | Use no-store for responses that must not be written down. Nothing else has that effect. |
no-store used where no-cache was meant | Every navigation refetches everything, forever | The response is never stored, so there is nothing to revalidate against | Use no-cache for documents that must be current: it is a 304 on the common path, not a download. |
Server-derived ETag behind a load balancer | Conditional requests return full bodies at random | Two servers generate different validators for identical content | Derive the ETag from content, or drop it and rely on Last-Modified (Deploying a Frontend). |
Long max-age on the HTML document | A fix is deployed and a share of users do not receive it for a day | The browser does not ask, so nothing you deploy can reach them | no-cache on documents, long lifetimes only on content-addressed assets (Long-Lived Clients and Version Skew). |
Missing Vary: Accept-Encoding | Occasional corrupt or unreadable responses, usually behind a proxy | A compressed variant is stored and served where an uncompressed one was requested | Set Vary for every dimension the response actually varies on (Minification Is Not Compression). |
| Service worker caching the same URLs | Header changes have no effect at all | The service worker answers first; the HTTP cache is never consulted | Decide which layer owns the policy and keep the other one out of the way (Caching Strategies). |
1GET /app.css HTTP/1.12Host: example.com3If-None-Match: "9c2f0a1"4If-Modified-Since: Tue, 12 Aug 2025 09:14:22 GMT5 6--- unchanged: three lines, no body ---7 8HTTP/1.1 304 Not Modified9ETag: "9c2f0a1"10Cache-Control: max-age=60011 12--- changed: new validator, full body ---13 14HTTP/1.1 200 OK15ETag: "b71d3e8"16Last-Modified: Wed, 20 Aug 2025 16:02:05 GMT17Cache-Control: max-age=60018Content-Type: text/css19 20... the whole file again ...21 22--- what a content-addressed asset gets instead ---23 24HTTP/1.1 200 OK25Cache-Control: max-age=31536000, immutable26ETag: "a3f19c4"27 28... and is never conditionally requested again, because a change29 produces a different URL rather than a different body ...The third block is the point of the whole module. A 304 still costs a round trip; an immutable fresh entry costs nothing at all. You do not get there with better headers on the same URL — you get there by never reusing a URL for different bytes (Content-Hashed Assets).
How to build it
Most important first.
- Split your assets into two categories and treat them differently: content-addressed files whose names change when their bytes change, and everything else.
- Give content-addressed files a long freshness lifetime and
immutable. They can never be wrong, because a change produces a different URL (Content-Hashed Assets). - Give the HTML document a short lifetime or
no-cache, because it is the file that names all the others. A stale document pins a user to an entire stale application. - Use
ETags that are derived from content, so an unchanged file revalidates to a304even if it was rebuilt or served from a different machine. - Use
no-storefor anything that must not be written to disk — authenticated pages, anything with personal data in the body — and understand that you are giving up caching entirely for those responses (What the Frontend Is Responsible For in Auth). - Reason about the shared caches too.
privateands-maxageare how you say "the browser may keep this, the CDN may not" (CDN Delivery).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Caching has no direct accessibility surface, and the indirect one is significant: a cached repeat visit removes the blank-page interval entirely, and that interval is the part of loading that assistive technology cannot navigate.
- A stale cached document that still references assets which no longer exist produces a page that partially fails — often with the styles missing, which can leave text unreadable and focus order meaningless (Contrast, Colour and Motion).
- The back/forward cache restores a page with its scroll position, its form state and its focus intact. Breaking it — which is easy to do accidentally — turns an instant, state-preserving back navigation into a full reload that loses everything a user had entered (Scroll Restoration).
- When a cached version and a fresh version differ visibly, announce the change rather than silently swapping content under someone who is reading it (Live Regions and Announcement).
What can go wrong
- A long
max-ageon the HTML. There is no way to fix it remotely: the browser will not ask, and the user cannot be told to clear a cache they do not know exists (Long-Lived Clients and Version Skew). - An
ETagderived from a file identifier rather than content, so identical files on two servers produce different validators and every conditional request behind a load balancer misses. - Missing
Varyon a response that varies. The browser stores the compressed variant and serves it where the uncompressed one was needed, or stores one language and serves it to everyone (Internationalization). immutableon a URL that is not immutable. Users are now pinned to a stale file for the whole freshness window, with no mechanism to correct it.- A service worker with its own caching policy layered on top, so the HTTP headers you carefully set describe a cache that is never consulted (Caching Strategies).
- Caching an authenticated response in a shared cache because
privatewas missing, which serves one user's page to another. The mitigation for the previous failure becomes the cause of this one.
- Two tabs revalidate the same stale response at the same time and both make the request; the HTTP cache does not coalesce the way an application-level cache can (Five Components, One Request).
- A deploy lands between the document being served and its assets being requested, so a fresh document asks for assets from the previous build — or the reverse, which is worse.
- A service worker and the HTTP cache can hold different versions of the same URL, and which one answers depends on which layer sees the request first (Intercepting Fetch).
no-storeis the only directive that keeps a response off disk. If a page contains personal data and is not markedno-store, it can be recovered from the cache by anyone with access to the device.- A shared cache storing a
privateresponse is a cross-user data leak, and the header that prevents it is one word (Sessions in Security Engineering). - A cached response outlives a logout. Signing out clears a token; it does not clear the HTTP cache, so a back navigation can still render a page the user believes is gone (Session Expiry and the Refresh Race).
- Cache behaviour is observable, and differences in timing between a hit and a miss have been used to infer what other sites a user has visited. Cache partitioning by top-level site exists largely to close that class of attack.
- A long-lived cached script is a long-lived attack surface: if a compromised file was served with a long freshness lifetime, revoking it is genuinely hard (Software Supply Chain Security in Security Engineering).
- "
no-cachemeans do not cache." It means store it and revalidate before reuse.no-storeis the one that means do not cache. - "A
304is a cache hit." It is a round trip that avoided a body. Better than a download, far worse than not asking at all. - "
ETagandLast-Modifiedare alternatives, pick one." They answer conditional requests in different ways and are commonly sent together;ETagis stronger because it is not limited to one-second resolution. - "Cache headers on assets are enough." The document names the assets. If the document is over-cached, nothing downstream can be corrected (Deploying a Frontend).
- "A CDN cache and a browser cache are the same thing with different headers." They have different keys, different lifetimes, different users, and only one of them can be purged by you (CDN Delivery).
Measuring it, and what changes in the field
- The Network panel's size column distinguishes a memory-cache hit, a disk-cache hit, a
304, and a full download. Those are four different outcomes with four different costs (Debugging the Network). - Reload the page three ways — normal, hard, and with the cache disabled — and compare. Most caching bugs are visible only in the difference between them.
- Check the response headers on a real deploy rather than in development, because the layer that sets them is frequently a CDN or a proxy rather than your application (CDN Delivery).
- In the field, the ratio of repeat visits that make zero requests for static assets is the number this lesson is trying to move (Real User Monitoring).
- On a high-latency connection, a revalidation still costs a full round trip, so
immutableon genuinely immutable assets is worth much more than it looks. - On a device under storage pressure, cached responses are evicted and your carefully tuned lifetimes never apply. Caching is a probability, not a contract.
- On a shared or public device,
no-storeon authenticated responses stops being a nicety. - For a long-lived tab, the cache is what an old client will use when it finally asks for something, which is how version skew becomes a caching question (Long-Lived Clients and Version Skew).
- Long lifetimes make repeat visits fast and make mistakes permanent for the duration. The mitigation is content-addressed names, which requires a build that produces them.
- Short lifetimes keep you able to change things and cost a round trip per asset per visit, even when the answer is
304. no-storeprotects the content and gives up every performance benefit for those responses, including on the same session.- Sophisticated cache headers are set at a layer — CDN, proxy, framework — that frontend engineers often do not own, so the policy and the people who understand it can end up in different teams.
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.
- GENERALFreshness, validation and the directives are HTTP semantics, so they behave the same across browsers. What differs is heuristic freshness in the absence of headers, eviction policy under storage pressure, and how each browser treats a reload — Chromium, Firefox and Safari all vary in whether a reload revalidates subresources.
- BROWSER-SPECIFICCache partitioning by top-level site is now widespread but arrived at different times and with different granularity per browser, which means the old advice about sharing a cached library across sites via a public CDN is dead in current browsers and may still appear to work in older ones.
- NETWORK-SPECIFICThe value of avoiding revalidation scales with latency: on a fast connection a
304is nearly free and on a high-latency mobile link it is a full round trip per asset, which is whyimmutablematters far more in the field than it does in a local test.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — a cache entry is a replica with a lease, and every question here about staleness, revalidation and skew is the same question that domain asks about replicas that cannot be reached.