Cache-Control headers explained: no-cache, no-store, max-age
What each Cache-Control directive does, no-cache vs no-store, ETags and revalidation, and ready-to-use headers for static files, HTML, APIs and HLS video.
On this page 10 sections
The Cache-Control header tells browsers and shared caches such as CDNs whether they may store a response, how long it stays fresh and what to do once it goes stale. Most sites need only a handful of directives: max-age and s-maxage for lifetime, no-cache to force a check with the server before reuse, no-store to forbid storage, private and public for who may store it, and immutable for files whose URL changes with every version.
How browsers decide to reuse a response
Every HTTP cache, from Chrome on a student's phone to a CDN edge in Mumbai, makes the same three decisions, defined in RFC 9111. These are the browser and CDN layers of the stack described in our guide to types of caching.
- May I store it? Not if the response says no-store, and not in a shared cache if it says private.
- Is my copy still fresh? A copy is fresh while its age is below its freshness lifetime, which comes from s-maxage (shared caches only), max-age or the older Expires header. A fresh copy is used without contacting the server.
- If it's stale, is it still right? The cache sends a conditional request with the validator it stored: If-None-Match with the ETag, or If-Modified-Since with the Last-Modified date. If nothing changed, the server replies 304 Not Modified with no body, and the stored copy is reused.
If a response has no explicit lifetime, caches are allowed to guess. RFC 9111 suggests a heuristic of up to 10% of the time since Last-Modified, and status codes such as 200, 301 and 404 can be cached this way by default. A file last modified 100 days ago could be treated as fresh for 10 days. That is why every response you care about should carry an explicit Cache-Control header.
The directives that matter
| Directive | What it means | Typical use |
|---|---|---|
| max-age=N | Fresh for N seconds, in any cache | Static files, public pages |
| s-maxage=N | Overrides max-age for shared caches (CDNs, proxies) only; once stale, they must revalidate | A different lifetime at the CDN than in browsers |
| no-cache | May be stored, but must be revalidated with the server before every reuse | HTML pages and anything that must always be current |
| no-store | Must not be stored at all | OTP, payment and other sensitive responses |
| private | Only the user's own browser may store it | Logged-in pages, personal API responses |
| public | Any cache may store it, even for a request that carried an Authorization header | Rarely needed |
| must-revalidate | Once stale, never reuse without a successful check, even if the server is unreachable | Data where a stale answer is worse than an error |
| immutable | Won't change while fresh, so skip revalidation even on reload | Fingerprinted files such as app.3f9c2a.js |
| stale-while-revalidate=N | After expiry, serve the stale copy for up to N seconds while refreshing in the background | Pages and API responses where a few seconds of staleness is fine |
| stale-if-error=N | If the server returns 500, 502, 503 or 504, serve the stale copy for up to N seconds | Staying up during an origin outage |
Directives combine with commas, as in Cache-Control: public, max-age=31536000, immutable. Two support notes from MDN's compatibility data: Firefox and Safari honour immutable but Chrome doesn't implement it, which is harmless, and stale-while-revalidate works in current Chrome, Firefox and Safari as well as at CDNs such as Cloudflare.
no-cache vs no-store
These are the most confused directives in HTTP, and the names don't help.
- no-cache does not mean "don't cache". It means "keep it if you like, but check with me before every reuse". With an ETag, that check usually ends in a tiny 304 response, so no-cache is a cheap way to get always-current HTML.
- no-store means "don't cache". Nothing may keep the response, so every request downloads it in full. Use it for responses that should never sit on a disk: OTP screens, payment pages, account settings.
max-age=0 sits in between. It makes a response stale immediately, which normally leads to revalidation, but RFC 9111 still lets a cache that can't reach the server serve the stale copy. Adding must-revalidate closes that gap, so max-age=0, must-revalidate behaves like no-cache. Today you can simply write no-cache; max-age=0 began as a workaround for old HTTP/1.0 caches that didn't understand no-cache. Browsers also send max-age=0 or no-cache in their request headers when a user reloads a page.
private, public and shared caches
private is what keeps personal data out of CDNs and proxies. A student's dashboard, "My courses" page or marks API should send Cache-Control: private together with no-cache or a short max-age. The student's own browser can still reuse the response, but nothing shared may store it.
You rarely need public. Most responses are already cacheable by shared caches, and its main practical effect is to let them store responses to requests that carried an Authorization header, which they must otherwise not reuse. Adding public to an authenticated API response is an easy way to leak it.
Over HTTPS, the shared caches in the path are normally only the ones you run or pay for, such as your CDN and reverse proxy, because nobody else can read the traffic (our explainer on what HTTPS protects covers why). That doesn't make private optional: your own CDN is exactly the shared cache that could mix up two students.
ETags and revalidation
An ETag is a validator, an opaque string the server attaches to a response, such as ETag: "v42-3f9c". When the cached copy goes stale, the browser sends If-None-Match: "v42-3f9c", and if the server's current ETag matches, it answers 304 Not Modified without a body. Last-Modified with If-Modified-Since works the same way with a timestamp, but only to the nearest second.
A weak ETag, written W/"v42", promises equivalent content rather than byte-for-byte identical content, which is fine for pages that differ in trivial ways. Two practical rules:
- Keep ETags consistent across servers. If each app server computes a different ETag for the same content, a revalidation that lands on another server becomes a full download.
- Remember that revalidation still costs a round trip. For a student on patchy 4G, 25 revalidations on every page load add up. Fingerprinted files with immutable avoid them entirely.
Recipes by asset type
| Asset | Headers | Why |
|---|---|---|
| Fingerprinted JS, CSS and fonts (app.3f9c2a.js) | Cache-Control: public, max-age=31536000, immutable | A new version gets a new URL, so the old one can live for a year |
| Images and PDFs without a version in the URL | Cache-Control: max-age=86400, plus an ETag | A day of reuse, then a cheap check |
| Public HTML (home page, course catalogue) | Cache-Control: no-cache, plus CDN-Cache-Control: max-age=300 | Browsers always check; the CDN serves it for five minutes. CDN-Cache-Control (RFC 9213) is honoured by CDNs that support it, such as Cloudflare, where HTML also needs a cache rule before it is cached at all |
| Logged-in HTML (dashboard, My courses) | Cache-Control: private, no-cache | Personal, so only the user's browser may keep it, and it checks every time |
| Personal API JSON (marks, progress) | Cache-Control: private, no-cache, or private, max-age=30 for data polled often | Personal but cheap to revalidate |
| Public API JSON (course list, test schedule) | Cache-Control: max-age=60, stale-while-revalidate=60 | Shared, and a minute of staleness is fine |
| Login, OTP and payment responses | Cache-Control: no-store | Should never be kept anywhere |
| Signed download links for notes | Cache-Control: private, with a max-age shorter than the link's expiry | A copy shouldn't outlive the permission to see it |
Caching HLS playlists and segments
A lecture streamed over HLS is a set of small files, and each kind needs different headers. Media segments (.ts or .m4s files, a few seconds of video each) never change once written, while live media playlists (.m3u8) are rewritten every few seconds.
| File | Changes? | Suggested Cache-Control |
|---|---|---|
| Media segments | Never, if every version has its own URL | public, max-age=31536000, immutable |
| Recorded (VOD) media playlist, ending in #EXT-X-ENDLIST | Not after publishing | max-age=86400 or longer, unless it embeds per-student tokens |
| Live media playlist | With every new segment | About half the target duration: max-age=3 for 6-second segments |
| Multivariant (master) playlist | Rarely | A few minutes, such as max-age=300 |
| Encryption key responses | Per user or session | private, with no-store or a short max-age |
Two rules explain the live-playlist figure. RFC 8216 requires a live server to publish each new playlist between half and one and a half target durations after the previous one, and the HLS second-edition draft recommends caching non-blocking playlist responses for half the target duration in its low-latency profile. Cache a live playlist for longer and students drift behind the live edge, or run out of buffer while the stale playlist lists no new segments.
One more trap: if your playlists carry per-student signed URLs, the playlist itself is personal and must be private, while the segments it points to can still be shared, provided the token isn't part of the CDN's cache key. CDN caching explains cache keys, and HLS vs DASH compares the formats.
Mistakes that serve stale or private data
- A long max-age on a URL that never changes. Put max-age=31536000 on /static/app.js and students keep the old file for a year after you ship a fix. Only fingerprinted URLs get long lifetimes; everything else relies on cache invalidation.
- Personal pages without private. If a CDN rule caches HTML and a dashboard response lacks private, one student's page can be served to the next. Mark every logged-in response private, and check what your CDN rules override.
- Treating no-cache as "don't store". It isn't. Use no-store for responses that must not be written to disk.
- A premature permanent redirect. A 301 is cacheable by default, and browsers can hold on to it for a long time. Test with a 302 and switch to 301 once you're sure.
- Varying on high-variety headers. Vary: User-Agent or Vary: Cookie tells shared caches to keep a separate copy for every distinct value, which quietly wrecks your hit ratio.
Key takeaways
- Cache-Control answers three questions for every cache: may I store it, how long is it fresh, and what happens once it's stale.
- no-cache means revalidate before every reuse; no-store means never store. max-age=0 on its own is weaker than no-cache.
- Give fingerprinted files max-age=31536000, immutable, and keep HTML on no-cache or a short lifetime.
- Mark anything personal private, and use CDN-Cache-Control or s-maxage when the CDN should keep a copy longer than browsers do.
- For HLS, cache segments for a long time and live playlists for no more than half the target duration.
Frequently asked questions
What is cache control header?
Cache-Control is an HTTP header that carries caching instructions, called directives, between servers, browsers and intermediaries such as CDNs. On a response, it says whether the response may be stored, by which kinds of cache, how long it stays fresh and whether it must be checked with the server before reuse. On a request, a browser uses it to ask for a fresh copy, for example when a user reloads the page.
What is cache control no store?
Cache-Control: no-store tells every cache, including the browser, not to keep any part of the request or response, so each request downloads the full response from the server. Use it for OTP screens, payment and account pages, and API responses carrying sensitive personal data. It is not access control, though: RFC 9111 warns that a misbehaving cache might ignore it, so protect the data itself as well.
What is cache control max age 0?
Cache-Control: max-age=0 marks a response as stale the moment it arrives, so caches normally revalidate it with the server before reusing it, which is cheap when the server supports ETags. Unlike no-cache, it still lets a cache that can't reach the server hand out the stale copy. If that isn't acceptable, send max-age=0, must-revalidate, or simply no-cache, which every modern cache understands.
What is cache control immutable?
immutable, defined in RFC 8246, tells browsers that a response will not change while it is fresh, so they can skip revalidation even when the user reloads the page. It is meant for fingerprinted URLs such as app.3f9c2a.js, typically sent as public, max-age=31536000, immutable. Firefox and Safari support it. Chrome doesn't implement the directive, but the rest of the header still works there.