Cache-Control explained: no-cache, no-store, max-age
HTTP caching is controlled almost entirely by one response header, and its two most famous directives are named misleadingly. no-cache does not mean "do not cache". It means "cache this, but check with me before using it". The directive that actually forbids storing anything is no-store. Most caching confusion unwinds from that one misnaming.
You can see the Cache-Control header any site sends with the HTTP Headers tool, and the rest of this post explains what you will find there.
The mental model: fresh vs stale
Every cached response is either fresh or stale, decided by its age against its lifetime:
- A fresh response is served straight from cache. No request goes out at all. This is the fast path: zero network.
- A stale response is not necessarily discarded. The cache asks the origin "has this changed?", a step called revalidation. If the server answers
304 Not Modified, the cached copy is reused and only headers crossed the wire.
Cache-Control directives set the lifetime and the rules for both phases.
The directives that matter
| Directive | Meaning |
|---|---|
max-age=3600 | Fresh for 3600 seconds from when it was generated |
no-cache | May be stored, but must revalidate before every use |
no-store | Never write this to any cache at all |
private | Browser caches only, never shared caches or CDNs |
public | Shared caches may store it, even some normally uncacheable responses |
s-maxage=600 | Overrides max-age for shared caches (CDNs) only |
must-revalidate | Once stale, never reuse without a successful revalidation |
immutable | Will never change during its lifetime; skip even the revalidation on refresh |
stale-while-revalidate=60 | May serve stale for 60s while refreshing in the background |
Three of these deserve expansion:
no-cache vs no-store. no-cache gives you correctness with efficiency: the user always sees current content, but unchanged content costs only a 304 round trip, not a full download. no-store is for content that must not exist on disk at all: banking pages, medical records, anything sensitive. Using no-store where no-cache suffices throws away the entire benefit of conditional requests.
private vs public. A personalized page, anything varying per cookie or user, must be private, or a CDN can serve one user's account page to another. This exact misconfiguration has caused real data leaks at real companies. Default to private for anything behind authentication.
immutable. Solves a subtle problem: when a user hits reload, browsers traditionally revalidate everything on the page, even resources that are still fresh. For fingerprinted assets, that is thousands of pointless 304s. immutable tells the browser to skip even that.
Revalidation: ETags and Last-Modified
Revalidation needs something to compare. The server provides a validator with the original response:
ETag: "abc123", an opaque fingerprint of the content. The browser sends it back asIf-None-Match: "abc123"; matching content earns a304.Last-Modified, a timestamp, compared viaIf-Modified-Since. One second resolution, so ETags are preferred where both exist.
One operational gotcha: default ETags in nginx and Apache derive from file metadata, so the same file on two servers behind a load balancer can carry different ETags, quietly breaking revalidation. Fingerprinted filenames avoid the problem entirely.
The recipes
Fingerprinted static assets (app.4f8a2c.js, hashed filenames):
Cache-Control: public, max-age=31536000, immutable
Cache for a year, never ask again. Safe because a content change produces a new filename; the old URL genuinely never changes. This pattern, hashed names plus long max-age, is the backbone of modern frontend performance, and every bundler supports it out of the box.
HTML pages:
Cache-Control: no-cache
HTML is the entry point that references the fingerprinted assets, so users must always get the current version, but a 304 makes the check cheap. Never give HTML a long max-age: users would be stuck on old deploys with broken references until it expires.
Authenticated API responses:
Cache-Control: private, no-cache
Sensitive data:
Cache-Control: no-store
Public API or CDN cached page, freshness within a minute acceptable:
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
The CDN serves instantly, refreshes behind the scenes, and users near a cache node almost never wait on your origin. Purging by tag or URL on deploy makes even the 60 seconds negotiable.
How do you debug cache behavior?
- DevTools Network tab. "from disk cache" or "from memory cache" means the fresh path; a
304status means revalidation happened; a full200with transfer size means the cache did not help. - CDN headers. Look for
Age(seconds the response sat in a shared cache) and vendor headers likex-cache: HITorcf-cache-status. AnAgethat never rises means the CDN is not storing it, and the reason is nearly always aCache-ControlorSet-Cookieheader on the response. - A hard refresh bypasses caches, so "it works after Ctrl+Shift+R" is the signature of a stale cache problem, usually HTML cached longer than intended.
One header worth knowing when things misbehave: Vary. It tells caches which request headers change the response. Vary: Origin matters for cached CORS responses, and a stale cached CORS header is a classic source of the errors dissected in our CORS guide.
To inspect what any URL actually sends, Cache-Control, ETag, Age, Vary, and all, run it through the HTTP Headers tool. For the broader header landscape beyond caching, see HTTP headers explained.