The Cache-Control directives map: no-store, no-cache, private, must-revalidate
Five directives, one table, and four ways getting one of them wrong actually breaks a site: a personal page cached on a shared CDN, a logged-out user seeing a previous session on the back button, and two more.Five Cache-Control directives cover almost every caching decision a browser or a CDN makes: no-cache, no-store, private, public, and must-revalidate. Getting one of them wrong does not throw an error. It shows a stranger someone else's account page, or shows a logged-out user a page from before they logged out. The table below covers what each directive actually does, followed by four specific ways they break when set wrong.
The reference table
Directives combine. A response typically carries two or three of these together, not one in isolation.
| Directive | What it means | Who can store the response | Common pairing |
|---|---|---|---|
no-cache |
Can be cached, but must be revalidated with the origin (an ETag or Last-Modified check) before being served from cache | Browser and shared caches (CDN, proxy) | no-cache, must-revalidate |
no-store |
Must never be cached anywhere, in any form, on disk or in memory | Nobody | no-store, private |
private |
Can be cached, but only by the end user's own browser, never by a shared cache | Browser only | private, max-age=0, must-revalidate |
public |
Can be cached by any cache, including a CDN, even for responses that would otherwise be treated as private (an authenticated request with a status code that is normally cacheable) | Browser and shared caches | public, max-age=3600 |
must-revalidate |
Once the cached copy passes its max-age, it cannot be served stale under any circumstance, including when the origin is unreachable |
N/A (modifies expiry behavior, does not set storage scope on its own) | max-age=60, must-revalidate |
The distinction that trips people up most: no-cache still allows storage, it forces a revalidation round trip first. no-store is the one that actually prevents storage. A page with sensitive, one-time content wants no-store. A page that changes often but is fine to serve after a quick freshness check wants no-cache.
Failure mode 1: a missing private directive leaks personal data through a shared cache
A response is only safe on a CDN or a shared proxy if it has no user-specific content, or if it explicitly carries private. Without that directive, a shared cache is allowed to treat a response as cacheable for everyone, even if the response body only makes sense for the one user who requested it.
This has happened in production: a personalized page, an account dashboard, or an API response scoped to one session gets served from a shared cache, and the next visitor who hits the same URL gets the previous visitor's content. The fix is not complicated once you know to look for it, Cache-Control: private, no-store on anything that includes another person's data, but the failure mode itself is invisible in testing unless two different users hit the same cached URL close together.
Failure mode 2: the back button shows a page from after logout
A user logs out, hits the back button, and sees their old account page rendered from the browser's history cache, sometimes with content that should no longer be visible to them. OWASP's testing guide lists this as a standard authentication weakness, and it is a common finding in real security reviews.
The part that catches people out: no-cache alone does not fix it. Modern browsers restore a page from the back-forward cache (bfcache) without re-running the page's JavaScript or re-fetching the network response at all, so a revalidation directive that depends on a network round trip never gets a chance to run. no-store is the directive that actually blocks bfcache eligibility, because it tells the browser not to keep the response around in any form for later reuse. A page with authenticated, sensitive content needs no-store, not just no-cache, if the back button is a real concern.
Failure mode 3: a CDN treats no-cache as fully cacheable
Not every CDN and reverse proxy implements the full directive set the same way. Some intermediaries have historically ignored the distinction between no-cache and full cacheability, or applied their own default TTL when a response carries no explicit max-age, overriding what the origin intended.
A related, more common version of this: the s-maxage directive, which only shared caches respect, silently overrides max-age for a CDN while the browser keeps following max-age. A response meant to be fresh in the browser but cached longer at the edge, or the reverse, needs both values set deliberately. Checking what a specific CDN actually does with a given header, rather than assuming spec compliance, is the only reliable way to catch this before it reaches production.
Two-minute self-check
Confirm what a live response actually sends, rather than what the code is supposed to send:
curl -sI https://example.com/dashboard | grep -i cache-controlRun it against both an authenticated page and a static asset on the same domain. They should return different values, an authenticated page should never come back with a bare public or no Cache-Control header at all.
Failure mode 4: a hard refresh behaves differently than a normal navigation
A normal link click or address-bar navigation respects max-age and serves from cache if the response is still fresh. A hard refresh (Shift+Reload, or an empty-cache reload) is meant to bypass the cache entirely and revalidate everything, but browsers do not apply that consistently across resource types, and some intermediary caches ignore the browser's cache-busting request headers altogether.
The practical effect: a developer testing a deploy with a hard refresh sees the new version and assumes every user does too, while a user doing a normal reload keeps seeing the stale cached version until max-age actually expires. must-revalidate closes part of this gap, once a cached response is stale, it forces a revalidation on every subsequent request instead of letting an intermediary serve it past expiry under weak network conditions, but it does not shorten a max-age that was set too long in the first place.
Choosing directives by content type
- Authenticated, sensitive pages (account, checkout, admin):
no-store, private - Personalized but non-sensitive pages (a dashboard that is fine to briefly re-render stale):
private, max-age=0, must-revalidate - Static assets with a versioned filename (hashed JS/CSS bundles):
public, max-age=31536000, immutable - API responses that change frequently but are safe to serve briefly stale:
public, max-age=60, s-maxage=300
The versioned-filename case is worth calling out on its own: once a build hash is in the filename, the content at that URL never changes, so an aggressive, long max-age is correct rather than risky. The failure modes above apply almost entirely to URLs that stay the same while their content changes underneath them.