Cache-Control headers: the only guide you'll actually need
Cache-Control decides what gets cached, by whom, and for how long. It looks cryptic and is mostly six words you actually use. Here they are, plus the one distinction that trips everyone.
The Cache-Control header answers three questions: may this be cached, by whom, and for how long. Six directives cover almost every case — public, private, max-age, s-maxage, no-store and stale-while-revalidate. Cache fingerprinted assets for a year, give HTML a short freshness window with stale-while-revalidate, and keep genuinely personal responses out of shared caches.
Cache-Control has a reputation for being fiddly, mostly because the specification contains a lot of directives nobody uses. Strip it back to the ones that earn their place and it is straightforward. The complexity that remains is real, though, and it lives in one distinction that catches almost everyone — so we will get to that early.
The directives worth knowing
| Directive | What it says | Who obeys it |
|---|---|---|
public | Any cache may store this | Browsers and CDNs |
private | Only the user's own browser may store it | Browsers only |
max-age=N | Fresh for N seconds | Everyone |
s-maxage=N | Fresh for N seconds in shared caches, overriding max-age | CDNs and proxies |
no-store | Do not store this anywhere, at all | Everyone |
no-cache | Store it, but revalidate before every use | Everyone |
must-revalidate | Once stale, never serve without checking | Everyone |
immutable | This will never change; do not revalidate on reload | Browsers |
stale-while-revalidate=N | Serve stale for up to N seconds while refreshing behind the scenes | Mainly CDNs |
stale-if-error=N | Serve stale for up to N seconds if the origin errors | Mainly CDNs |
no-cache does not mean do not cache
This is the most misleadingly named directive in HTTP and it is worth being clear about. no-cache means "you may keep a copy, but you must check with the origin before using it". The response is still stored; it just cannot be served without a revalidation request.
no-store is the one that means do not cache. If you want a response never written to disk anywhere, that is the directive you want.
The practical difference matters: no-cache with an ETag is quite efficient, because the revalidation usually returns a 304 with no body. no-store means the full response is transferred every single time.
The one distinction that trips everyone: max-age vs s-maxage
Browsers and CDNs are both caches, but you rarely want them holding content for the same length of time.
Consider a news article. You want your CDN to cache it for an hour so your origin is protected. You do not want a visitor's browser holding it for an hour, because if you correct an error and purge the CDN, that visitor still has a stale copy and no way for you to reach it.
s-maxage solves this by applying only to shared caches:
Cache-Control: public, max-age=60, s-maxage=3600
The browser caches for a minute. The CDN caches for an hour. You can purge the CDN and be confident everyone gets the correction within sixty seconds, while your origin still only serves the page once an hour.
This is the shape you want for almost all HTML: short in the browser, long at the edge, purge on change. The reason it matters is that you can invalidate a CDN and you cannot invalidate a browser.
stale-while-revalidate, the underrated one
Normally when a cached response expires, the next visitor waits while the cache fetches a fresh copy. That visitor pays the full origin latency, which is a slightly unfair way to distribute the cost of freshness.
stale-while-revalidate changes the deal. Within the specified window past expiry, the cache serves the stale copy immediately and refreshes in the background. Nobody waits. The next visitor gets the fresh version.
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=86400
Its sibling stale-if-error is worth adding wherever it is tolerable: if the origin returns a 5xx or times out, serve the stale copy rather than an error page. It is the cheapest resilience you will ever configure — your site staying up through an origin outage because the edge kept serving what it had.
Recipes
Fingerprinted static assets — app.a1b2c3.js, styles.d4e5f6.css
public, max-age=31536000, immutable. A year, and never revalidated. Safe because the filename changes when the content does. immutable additionally stops browsers revalidating on a manual reload.
Unfingerprinted static files — /logo.png
public, max-age=86400. A day, since you have no way to bust it other than waiting or renaming.
HTML you can purge
public, max-age=60, s-maxage=86400, stale-while-revalidate=604800, stale-if-error=604800. Long at the edge, short in the browser, purge on publish.
HTML you cannot purge
public, max-age=300, stale-while-revalidate=3600. Shorter, since expiry is your only invalidation mechanism.
API responses, public data
public, max-age=0, s-maxage=60, stale-while-revalidate=300. Never trusted in the browser, briefly cached at the edge — which absorbs the majority of duplicate requests.
Personal pages — cart, account, dashboard
private, no-store. Not no-cache. Nothing personal should sit in a shared cache under any circumstances.
A blanket no-store applied everywhere "to be safe" is the single most common cause of a bad cache hit ratio. It is also often applied by framework defaults without anyone choosing it. Check what your application actually sends before assuming your configuration is what is in effect.
ETags and revalidation
Cache-Control decides how long something is fresh. When it stops being fresh, validators decide whether it needs re-downloading.
An ETag is a fingerprint of the response. On revalidation the client sends If-None-Match with that value, and if nothing changed the server replies 304 Not Modified with no body. Last-Modified works similarly with a timestamp and If-Modified-Since, at one-second precision.
A 304 still costs a round trip, which is why it is not a substitute for a decent max-age — it saves bandwidth, not latency. Send validators anyway, because they make the expensive case much cheaper.
Check what you are actually sending
curl -sI https://example.com/ | grep -iE 'cache-control|etag|age|vary'
Run it against your homepage, a static asset and an API endpoint. The Age header tells you how long the response has been sitting in a cache, which is a quick way to confirm the CDN is actually caching rather than just relaying.
Frequently asked questions
What does Cache-Control do?
Cache-Control is an HTTP response header that instructs browsers and shared caches such as CDNs whether a response may be stored, who may store it, and for how long it remains fresh. It is the primary mechanism for controlling caching behaviour, taking precedence over the older Expires header.
What is the difference between no-cache and no-store?
no-cache permits a response to be stored but requires the cache to revalidate with the origin before serving it, so an unchanged response can be confirmed with a lightweight 304 reply. no-store forbids storing the response anywhere at all, meaning the full body is transferred on every request. Only no-store actually prevents caching.
What is the difference between max-age and s-maxage?
max-age applies to all caches, including the visitor's browser. s-maxage applies only to shared caches such as CDNs and proxies, and overrides max-age for them. The common pattern is a short max-age with a much longer s-maxage, so the CDN protects your origin while browsers hold content only briefly — important because you can purge a CDN but cannot purge a browser.
What is stale-while-revalidate?
stale-while-revalidate defines a window after a response expires during which a cache may serve the stale copy immediately while fetching a fresh one in the background. It removes the latency penalty that otherwise falls on whichever visitor happens to arrive first after expiry, so no individual user waits for the origin.
How long should I cache static assets?
One year — max-age=31536000 — with the immutable directive, provided the filenames contain a content hash such as app.a1b2c3.js. Because a change to the file produces a new filename, there is no staleness risk. Files without a fingerprint in the name should use something shorter, around a day, since renaming is the only way to invalidate them.
Why is my content not being cached by the CDN?
Usually because the response carries no-store, private or max-age=0, often added by a framework default rather than deliberately. Other frequent causes are a Set-Cookie header on responses that should be shared, or a Vary header on something with unlimited values such as User-Agent. Run curl -I against the URL and read the headers your origin actually sends.
Set these once per content type and you will rarely think about the header again, which is exactly how a good caching configuration should feel.
Mads has worked in IT — mostly hosting — since he was 16. He took an early stake in a SaaS company and helped grow it through to its acquisition by Visma, has built and run data-center networks, and served as CTO of a Danish data center. He started NordicCDN to make fast, secure infrastructure simple to use.