Learn the theory
HTTP Caching Explained: how it works
4 min read · Updated
The fastest request is the one you never send. HTTP caching lets browsers and CDNs reuse earlier responses, saving bandwidth and shaving hundreds of milliseconds off every page view. It is controlled almost entirely by a handful of response headers — and getting them wrong is why users sometimes see a stale site for days after a deploy, or why your API gets hammered by requests that should have been cached.
In short
- A cached response is either fresh (reuse it with no network request) or stale (it must be revalidated or replaced).
Cache-Control: max-age=Nsets how many seconds a response stays fresh.- Validators (
ETag,Last-Modified) let the browser ask “has it changed?” and get a tiny 304 Not Modified instead of the whole body. no-cachemeans “store it, but always revalidate”.no-storemeans “never keep it”. They are not the same.- For static files use content-hashed filenames with a one-year
immutablecache; for HTML useno-cachewith an ETag.
Freshness and validation
Every cached response goes through two questions. First: is it still fresh? If the response's age is below its max-age, the browser serves it directly from the cache — no network, no latency. Second, once it's stale: is it still valid? The browser can send a conditional request asking the server whether the stored copy still matches the current resource.
# First request
GET /app.js HTTP/1.1
HTTP/1.1 200 OK
Cache-Control: max-age=60
ETag: "v1"
(120 KB body)
# After 60 s the copy is stale -> conditional request
GET /app.js HTTP/1.1
If-None-Match: "v1"
HTTP/1.1 304 Not Modified <- ~350 bytes, no body
Cache-Control: max-age=60 <- freshness timer restartsCache-Control directives you need
| Directive | Meaning |
|---|---|
max-age=N | Fresh for N seconds after the response was generated. |
s-maxage=N | Like max-age, but only for shared caches (CDNs, proxies). |
no-cache | May be stored, but must be revalidated with the server before every reuse. |
no-store | Must not be stored anywhere. Use for sensitive data. |
must-revalidate | Once stale, don't serve it without revalidating (even if the network is down). |
public / private | private = only the user's browser may cache it; public = shared caches may too. |
immutable | The file will never change while fresh; don't revalidate it, even on reload. |
stale-while-revalidate=N | For N seconds after expiry, serve the stale copy instantly and refresh in the background. |
stale-if-error=N | Serve stale content if the origin errors. |
ETag and Last-Modified
Validators identify a specific version of a resource. An ETag is an opaque fingerprint (often a content hash) compared with If-None-Match. Last-Modified is a timestamp compared with If-Modified-Since. ETags are more precise; Last-Modified has only one-second resolution. If a response has neither a max-age nor a validator, browsers can't safely reuse it.
Reload, hard reload and the back button
| User action | What the browser does |
|---|---|
| Click a link / type a URL | Uses fresh cache entries without asking the server. |
| Back / forward button | Reuses cached pages aggressively, even stale ones. |
| Reload (F5) | Revalidates cached entries with conditional requests (unless immutable). |
| Hard reload (Ctrl/⌘+Shift+R) | Bypasses the cache entirely and downloads everything again. |
Recipes that work
| Resource | Recommended header | Why |
|---|---|---|
Hashed static assets (app.3f9c1.js) | public, max-age=31536000, immutable | The URL changes whenever the content does, so cache it for a year. |
| HTML documents | no-cache + ETag | Always check, but pay only for a 304 when nothing changed. |
| API responses (per user) | private, max-age=0 or short max-age + ETag | Never let a shared cache serve one user's data to another. |
| Public API / images | public, max-age=300, stale-while-revalidate=3600 | Fast for users, gentle on the origin. |
| Sensitive pages | no-store | Nothing stays on disk or in proxies. |
CDNs, shared caches and Vary
A CDN is a shared cache sitting between users and your server. s-maxage lets you give the CDN a different lifetime than browsers, and private keeps personalized responses out of it. If a response differs by request header — language, compression, Origin for CORS — add a Vary header (for example Vary: Accept-Encoding, Origin) so caches store separate variants instead of serving the wrong one.
Frequently asked questions
- What is the difference between no-cache and no-store?
no-cachelets a cache store the response but forces revalidation before every reuse.no-storeforbids storing it at all.- What does a 304 Not Modified response mean?
- The server is telling the browser that its cached copy is still current. The 304 has no body, so only headers cross the network, and the cached body is reused.
- How does ETag work?
- The server sends an
ETagidentifying the version of the resource. On later requests the browser sends it back inIf-None-Match; if it still matches, the server replies 304 instead of resending the body. - Why do users still see the old version of my site after a deploy?
- Their browser (or a CDN) is serving a cached copy that is still fresh under its
max-age. Use content-hashed asset filenames andno-cacheon the HTML so new deploys are picked up immediately. - What is stale-while-revalidate?
- A directive that lets a cache serve an expired response immediately while it fetches a fresh copy in the background, giving users instant responses without permanently serving stale data.
- Does a hard reload clear the cache?
- It bypasses the cache for that load and replaces the stored copies with fresh responses. A normal reload instead sends conditional requests.