Who is listening to this header
One response passes through several caches: the visitor's browser (private — serves one person), the CDN edge (shared — serves everybody), and sometimes a proxy in between. Cache-Control is how the origin talks to all of them at once, which is why directives exist to address them separately.
That distinction drives everything else. A logged-in dashboard may be cacheable in that user's browser and must never be cached at the edge where another user could receive it. A public marketing page is the opposite: cache it hard at the edge, briefly in the browser. Deciding "private or shared" first makes every other choice obvious.
The directives that actually matter
max-age=N — reusable for N seconds without asking again. The main dial.
s-maxage=N — same, but only for shared caches (the CDN). When present, the edge obeys it and ignores max-age, so you can keep something at the edge for an hour while browsers hold it for a minute.
public / private — public may be stored by any cache; private restricts storage to the individual browser. Anything user-specific must be private.
no-cache — store it, but revalidate with the origin before each reuse. Cheap when unchanged: the server can answer 304 Not Modified with no body.
no-store — never write it to any cache, in memory or on disk. Reserve this for genuinely sensitive responses; it is not "please be fresh", it is "keep no copy at all".
immutable — this body will never change at this URL, so do not even revalidate on reload. Only truthful for versioned filenames.
stale-while-revalidate=N — after expiry, keep serving the stale copy for up to N seconds while a fresh one is fetched in the background. The visitor never waits for the refetch.
Three recipes that cover most sites
Versioned static assets — the filename changes whenever the content does, so the URL is safe to cache forever. This is the single biggest, safest caching win available.
HTML pages — the URL stays the same while the content changes, so it needs a short life. A brief max-age plus stale-while-revalidate gives you speed without serving yesterday's page.
Private or personalised responses — dashboards, carts, anything behind a login. Keep it out of shared caches; the browser may hold it briefly if that is safe for you.
Copy-paste starting points — tune the numbers to your release cadence
# versioned assets: /js/app.a1b2c3.js
Cache-Control: public, max-age=31536000, immutable
# HTML pages (short at the browser, longer at the edge, no waiting on refresh)
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=86400
# per-user pages: never at the edge
Cache-Control: private, no-store
# an API response that changes often but can lag a little
Cache-Control: public, max-age=0, s-maxage=30, stale-while-revalidate=60
no-cache vs no-store: the mistake worth avoiding
These read like synonyms and behave nothing alike. no-cache permits storage but requires revalidation before reuse — the copy stays, and when it is still current the server replies 304 with no body, which is very cheap. no-store forbids keeping the response anywhere.
Reaching for no-store when you mean no-cache throws away every optimisation for no benefit: every request becomes a full download even when nothing changed. Use no-store only when a stored copy is itself the problem — bank statements, password reset pages, anything that must not sit in a shared machine's cache. For "always show the latest", no-cache is the correct and far cheaper answer.
How this interacts with your CDN
The headers your origin sends are what the edge obeys when it decides how long to keep a copy — which makes Cache-Control the control surface for your whole delivery chain, not a browser detail. Send s-maxage and the edge follows it; send no-store and the edge refuses to cache at all, so every request travels to your origin and you have effectively turned the CDN off for that response.
On cdn.com.tr you can also set caching behaviour per delivery rule in the panel when you cannot change the application's headers — useful for legacy apps you would rather not touch. When you do change what a cached URL returns, remember the edge is still holding the previous copy until it expires: that is what a purge is for.
Checking what you actually send
Assumptions about headers are frequently wrong — a framework, plugin or web-server default often overrides what you think you configured. Verify with one command per URL type, and check both a versioned asset and an HTML page, since they should look completely different. Watch also for a Set-Cookie on a response you meant to cache publicly: many caches refuse to store those, and it is a common reason a page mysteriously never caches.
Read the real response headers
# see what the edge and origin actually say
curl -sI https://example.com/ | grep -i "cache-control\|age\|set-cookie"
# compare a versioned asset (should be a long max-age)
curl -sI https://example.com/js/app.a1b2c3.js | grep -i cache-control
Frequently asked questions
What is a good max-age for HTML pages?
Short — seconds to a few minutes — because the URL stays the same while the content changes. Pair it with a longer s-maxage at the edge and stale-while-revalidate so visitors get an instant response while the refresh happens in the background.
Is Expires still needed alongside Cache-Control?
No. Cache-Control supersedes Expires and wins wherever both appear. Expires only matters for extremely old clients; sending it as well is harmless but adds nothing.
Why is my page not being cached even with a long max-age?
Most often a Set-Cookie header on the response, a private or no-store directive somewhere in the chain, or a query string that makes each request a unique cache key. Inspect the actual response headers before changing configuration.
Does immutable really mean forever?
It means "the body at this URL will not change", so caches skip revalidation entirely. That is only true for versioned filenames. Putting immutable on a URL you later overwrite is how visitors get stuck on an old file with no clean way to fix it.