How HTTP Caching Works: Cache-Control, ETags and CDNs Explained
The headers that decide whether a page loads from your device in milliseconds or makes the long trip back to the server

The fastest web request is the one that never leaves your device. Every time a page loads instantly on a second visit, a quiet agreement between your browser, a CDN and the origin server made that possible, written in a handful of HTTP headers most people never look at.
This guide explains how HTTP caching works: where copies of your pages get stored, how a cache decides whether a copy is still fresh, how it checks with the server cheaply when it is not, and the header recipes that make a site both fast and correct after every deploy.
Where responses get cached
The rules live in the IETF's HTTP Caching standard, RFC 9111, which replaced the older RFC 7234 in 2022. It describes two broad kinds of cache:
- Private caches belong to one user, almost always the browser. They can safely hold personalized pages such as a dashboard or an account screen.
- Shared caches sit between users and the server and serve many people from one stored copy. MDN splits these into proxy caches you do not control and managed caches you deploy on purpose, such as a reverse proxy or a CDN, which you can usually purge from a dashboard or API.
Caching happens after the earlier steps of a request: your device has already looked up the site's address in DNS and, for most sites, set up an encrypted connection. A cache hit skips the network trip entirely, so it saves the most time of all.
Freshness: is the stored copy still good?
A cached response is either fresh, meaning the cache may reuse it without asking anyone, or stale, meaning it should check first. Freshness is a simple comparison: if the response's freshness lifetime is greater than its current age, it is fresh.
RFC 9111 says a cache works out the freshness lifetime from the first rule that applies:
- For a shared cache, the
s-maxagevalue inCache-Control. - The
max-agevalue. - The
Expiresdate minus the response'sDate. - Otherwise, a heuristic guess.
Age comes from how long the copy has been stored, plus whatever a shared cache reports in the Age header. MDN's example: a response with max-age=604800 (one week) that arrives with Age: 86400 has six days of freshness left.
The Cache-Control directives that matter
Most of the behavior you will ever need comes from a small set of directives. The names are famously misleading, so this table is worth bookmarking.
| Directive | What it really means | Typical use |
|---|---|---|
max-age=N | Fresh for N seconds in any cache | Static assets, public pages |
s-maxage=N | Overrides max-age for shared caches only | Short browser life, longer CDN life |
no-cache | May be stored, but must be revalidated before every reuse | HTML pages, API responses |
no-store | Must not be stored at all | Truly sensitive responses |
private | Only the user's own browser may store it | Personalized pages |
public | Storable even when the request carried an Authorization header | Rarely needed; use with care |
immutable | Do not revalidate while fresh, even on reload | Fingerprinted files |
stale-while-revalidate=N | Serve stale for up to N seconds while refreshing in the background | Pages that can be slightly out of date |
The classic mix-up is no-cache versus no-store. no-cache still allows storage; it just forces a check with the server every time. MDN advises against handing out no-store liberally, because it switches off useful features such as the browser's back/forward cache. For personalized content, no-cache, private is usually the better fit.
Revalidation: ETags and the 304 shortcut
When a copy goes stale, the cache does not have to download the whole thing again. It sends a conditional request carrying a validator it saved from the original response:
ETag/If-None-Match: the server labels each version with an opaque tag, and the cache asks, "give me the file unless it still has this tag."Last-Modified/If-Modified-Since: the same idea using a timestamp.
If nothing changed, the server answers 304 Not Modified with no body, and the cache marks its stored copy fresh again. If the content changed, it gets a normal 200 OK with the new version. When both validators are sent, If-None-Match wins, and web.dev prefers ETags because timestamps are less precise.
# See a site's caching headers
curl -sI https://example.com/ | grep -iE 'cache-control|etag|last-modified|age|vary'
# Replay a revalidation by hand (swap in the real ETag)
curl -sI https://example.com/ -H 'If-None-Match: "33a64df5"'
# HTTP/2 304 means: your copy is still good
This is the same trade-off engineers make with database indexes: a little extra bookkeeping up front buys much cheaper lookups later.
Recipes that work for most sites
Fingerprinted assets: cache for a year
Build tools can put a content hash in each filename, like app.3f9a1c.js. When the file changes, its URL changes, so browsers fetch the new one immediately and old copies simply stop being requested. That makes a very long lifetime safe:
Cache-Control: max-age=31536000, immutable
HTML and fixed URLs: always check
You cannot rename /index.html, favicon.ico or an API endpoint on every deploy, so let caches keep them but check before reuse. With an ETag in place, most of those checks end in a quick 304:
Cache-Control: no-cache
ETag: "v42-a7c9"
Let the CDN soften the edges
For pages that can lag by a few seconds, RFC 5861 adds stale-while-revalidate, which lets a cache answer instantly with the old copy while it fetches a new one in the background, and stale-if-error, which lets it serve a stale copy when the origin returns a 500, 502, 503 or 504. Some CDNs also honor a separate CDN-Cache-Control header, so you can give the edge a different policy from browsers. Cloudflare documents that when this header is present, it drives Cloudflare's caching while ordinary Cache-Control passes through untouched.
Cache-Control: max-age=60, stale-while-revalidate=30, stale-if-error=86400
Common HTTP caching mistakes
- Long
max-ageon unversioned files. Visitors may keep an oldstyle.cssfor days after you ship a fix. - Caching personal pages in shared caches. Mark anything tied to a logged-in user as
private; MDN notes a cookie alone does not make a response private. - Varying on too much.
Varyadds request headers to the cache key.Vary: Accept-Languageis reasonable, butVary: User-Agentcreates so many variants that hit rates collapse. - Skipping validators. Without an ETag or Last-Modified, a stale copy has to be downloaded in full instead of confirmed with a 304.
- Forgetting the cost side. Every cache miss is bandwidth and compute you pay for. Good cache headers are one of the cheapest ways to cut hosting and CDN bills.
Key takeaways
- Browsers keep private copies; CDNs and proxies keep shared copies for many users.
- A response is fresh while its age is below its lifetime, set by
s-maxage,max-ageorExpires. no-cachemeans "check first," not "don't store." Useno-storeonly when storage itself is the risk.- ETags turn stale copies into cheap 304 confirmations instead of full downloads.
- Fingerprint static files and cache them for a year; send
no-cacheplus validators for HTML.
Frequently asked questions
What is the difference between no-cache and no-store?
no-cache lets a cache store the response but requires it to revalidate with the server before each reuse. no-store forbids storing the response at all.
Why do visitors still see my old CSS after a deploy?
The file was probably served with a long max-age under the same URL. Put a version or content hash in asset filenames so every change gets a new URL.
Is a 304 Not Modified response bad for performance?
No. It has no body, so it is far smaller than a full response. It still costs a round trip, which is why fingerprinted assets use long lifetimes and skip revalidation entirely.
What does s-maxage do?
It sets the freshness lifetime for shared caches such as CDNs and overrides max-age for them, so you can let a CDN hold a page longer than browsers do.









Comments
No comments yet — be the first to share your thoughts.
Join the discussion