← Back to blog

Cache-Control Headers: Directives, Examples and Pitfalls

October 25, 2026
Cache-Control Headers: Directives, Examples and Pitfalls

A Cache-Control header is the instruction your server attaches to a response to tell browsers and shared caches such as CDNs whether they may store it, for how long, and when they must check back with the origin. Set it well and repeat visitors load your site from local disk. Set it badly and users get stale pages, or your server answers the same request thousands of times. This guide covers the directives you will actually use, what max-age really does, how ETag and Last-Modified feed 304 responses, and how to check the result.

Pingfloat
Check Your Site’s Health
Pingfloat brings website uptime monitoring and network diagnostics together, helping developers investigate issues alongside the headers their server sends.
Explore Pingfloat tools

TL;DR

  • Cache-Control: max-age=31536000, immutable is for versioned static files (a hash or version in the filename). Nothing else should get it.
  • no-cache does not mean "don't cache". It means store it, but revalidate before every reuse. no-store is the directive that prevents storage.
  • Use s-maxage when your CDN should keep a response longer or shorter than the browser does.
  • A 304 Not Modified is the cheap answer to a conditional request: headers only, no body.
  • Always verify the headers your server really sends, not the ones your config says it sends.

Table of Contents

What the Cache-Control header does

Cache-Control carries a comma-separated list of directives. It can appear in requests and responses, and it governs browser caches and shared caches. The current standard is RFC 9111. Directive names are case-insensitive, though lowercase is the convention, and a typical value looks like this:

Cache-Control: public, max-age=3600

There are two kinds of cache to keep in mind:

  • Private cache: the browser's own cache, which serves a single user.
  • Shared cache: a CDN, reverse proxy or corporate proxy, which can serve one stored response to many users.

Most confusion about caching comes from forgetting that a response may pass through both, and that each can follow different rules.

One point from the spec matters when you debug. Directives in a request are advisory. Caches and browsers may ignore them, so the origin server has to define the caching policy for each resource. Don't rely on a client to behave.

The directives you will use most

DirectiveWhere it actsWhat it does
max-age=NBrowser and shared cachesThe response is fresh for N seconds after it was generated
s-maxage=NShared caches onlyOverrides max-age for CDNs and proxies
no-cacheBothMay be stored, but must be revalidated before reuse
no-storeBothDo not store the response at all
privateBrowser onlyOnly the end user's cache may store it
publicBothAny cache may store it, even if it normally wouldn't
must-revalidateBothOnce stale, do not serve without revalidating
immutableBothThe content will not change during its freshness lifetime
stale-while-revalidate=NBothServe stale for N seconds while refreshing in the background
stale-if-error=NBothServe stale for N seconds if the origin returns an error

You rarely need more than three or four of these on one response. The rest of this article goes through the ones that cause real mistakes.

How max-age works

max-age=N sets the freshness lifetime in seconds, counted from the moment the response was generated, not from when the browser received it. While a stored response is fresh, the browser uses it without contacting your server at all. No request, no 304, no latency.

Some values worth memorizing:

DurationSeconds
5 minutes300
1 hour3600
1 day86400
1 week604800
1 year31536000

One year is the practical maximum people use for "cache forever". It is not a magic number, just a long enough lifetime that the file effectively stays until the URL changes.

When both Cache-Control: max-age and the older Expires header are present, Cache-Control takes precedence. Send max-age and you can stop worrying about Expires.

What happens when max-age runs out

The response becomes stale. That does not mean it is thrown away. The cache will typically send a conditional request, and if your server answers 304, the stored copy is fresh again without the body being downloaded a second time. That is the link between Cache-Control and validators, covered below.

no-cache vs no-store

This is the most commonly misread pair of directives.

So no-cache plus an ETag is a good fit for an HTML page that changes often but is wasteful to resend when nothing changed. Each load costs a round trip, but if nothing changed the reply is a tiny 304.

Use no-store for responses that should not sit on disk anywhere: account pages, banking screens, anything with personal data. The common anti-pattern is putting no-store on every response "to be safe". Performance guidance consistently points the other way: long-lived max-age on versioned static assets, and ETag-based revalidation only for dynamic content that needs origin verification. Blanket no-store throws away the cheapest speedup you have.

Caching static assets: the versioned-file pattern

The cleanest strategy is to put a version or content hash in the filename, such as app.3f9a1c.js or style.v234.css. Because the URL changes whenever the content does, you can tell every cache to keep the file for a year:

Cache-Control: public, max-age=31536000, immutable

The immutable directive tells the browser the response will not be updated during its freshness lifetime, so it does not need to revalidate. The draft behind it says clients should not send a conditional request during the freshness lifetime, for example on a reload.

Here is the trap. immutable is only safe on versioned URLs. Put it on /logo.png or /app.js and you can leave users stuck with an old file until the lifetime expires or they clear their cache by hand, because even a normal reload will not ask for an update. If you can't change the filename when the content changes, don't use it.

For unversioned assets that change occasionally, a gentler pattern is a moderate max-age with an ETag and a stale-while-revalidate window. One widely read caching write-up suggests a week of max-age plus a day of stale-while-revalidate, with HTML kept to a short lifetime. Treat those numbers as a starting point, not a rule. How fast can you tolerate users seeing an outdated file? That answer sets the number.

The same split applies if your content lives in a headless CMS: headless CMS architecture separates content delivery from presentation, which makes it natural to give long-lived caching to static files and revalidation to the resources that change often.

Caching static assets: the versioned-file pattern — overview diagram

Browser cache vs CDN cache: max-age and s-maxage

Sometimes you want your CDN to hold a page for an hour but want browsers to check back after a minute. That is what s-maxage is for. max-age targets browser caches and s-maxage targets shared caches. If both are set, shared caches follow s-maxage while browsers follow max-age.

Cache-Control: public, max-age=60, s-maxage=3600

Two details to remember:

  • s-maxage also carries the semantics of proxy-revalidate, so a shared cache must revalidate stale content before reuse.
  • private keeps a response out of shared caches. Use it for anything tied to one user, such as a personalized dashboard that is fine to cache in that user's own browser.

If you are unsure which layer is responding, look at the response headers from the outside. Our response headers inspector shows the cache headers your URL returns, and our HTTP headers walkthrough explains how to read the rest.

Revalidation: ETag, Last-Modified and the 304 response

Once a response is stale, the cache doesn't have to download it again. It can ask the server whether the stored copy is still good. That is a conditional request, and it uses a validator your server sent with the original response.

ETag vs Last-Modified

ETagLast-Modified
What it isAn opaque identifier for a version of the resourceA timestamp
Client sends it back asIf-None-MatchIf-Modified-Since
PrecisionExact: changes with the contentOne second at best
Typical sourceHash of the content or a version counterFile modification time

If a request carries both, If-None-Match takes precedence over If-Modified-Since. In practice, ETag is the stronger validator, since a file can be rewritten with identical content (new timestamp, same bytes) or modified twice within a second. Sending both is common and harmless.

What 304 Not Modified means

A 304 is the server's answer when a conditional GET or HEAD request arrives and nothing has changed. The If-None-Match header makes a request conditional: the server returns the resource with a 200 only if its ETag does not match. If it matches, the server returns 304 with no body, and the browser reuses its stored copy.

The exchange looks like this:

GET /pricing HTTP/2
If-None-Match: "a1b2c3"

HTTP/2 304 Not Modified
ETag: "a1b2c3"
Cache-Control: no-cache

A 304 is not an error and not a redirect in the sense users experience. It saves bandwidth because no response body is retransmitted. It still costs a round trip, which is why a long max-age beats revalidation for files that never change. For a longer reference, see our page on the 304 Not Modified status code, or browse the full HTTP status codes reference.

Serving stale content on purpose

Two extensions from RFC 5861 let a cache use a stale response deliberately.

  • stale-while-revalidate=N lets a cache serve the stale copy immediately while it refreshes in the background. The user never waits on revalidation. The original specification describes it as serving stale responses for up to the stated number of seconds after expiry.
  • stale-if-error=N lets a cache return a stale response when the origin fails with an error. Stale responses carry a non-zero Age header.

A combined example:

Cache-Control: max-age=300, stale-while-revalidate=600, stale-if-error=86400

This is as much an availability tool as a speed tool. With a generous window, visitors can keep getting pages during a short backend outage or slow spell, which hides backend instability from users. It isn't a substitute for fixing the outage. When your origin returns errors, see what a 503 Service Unavailable or a 504 Gateway Timeout means for your stack.

These are starting points that follow from the directives above, not universal rules. Adjust them to how often your content changes.

ContentSuggested headerWhy
Hashed JS, CSS, fonts, imagespublic, max-age=31536000, immutableURL changes when content changes
Unversioned images or filesmax-age=86400, stale-while-revalidate=604800 plus an ETagFast, with background refresh
HTML pages (public)no-cache plus an ETag, or a short max-ageAlways fresh or nearly so, cheap 304s
Personalized pagesprivate, no-cacheKeep out of shared caches, revalidate
Sensitive data and API responses with secretsno-storeNever write to disk
CDN-cached pagesmax-age=60, s-maxage=3600Short in browsers, longer at the edge

Our perspective

We build and run network diagnostic tools, so we spend a lot of time looking at response headers from the outside. The pattern we see most is not an exotic directive misused. It is a header that was configured at one layer and silently overridden at another: the app sets one policy, the reverse proxy or CDN adds a different one, and nobody checks what leaves the edge.

Our perspective — overview diagram

So our advice is plain. Decide a policy per content type, version your static files so you can cache them aggressively, and keep no-store for data that truly must not be stored. Then test the live URL. Caching bugs are rarely about what you meant to send. They are about what actually arrived.

We also think no-cache deserves a better reputation. Paired with a good ETag, it gives you correctness on every load for the price of a tiny 304. For HTML that is usually the right default.

— Andreas

Check what your server really sends

After any change, request the URL and read the headers. Look for Cache-Control, ETag, Last-Modified, Age and Vary. Then repeat the request, ideally with a conditional header, and confirm you get a 304 when you expect one.

Our free Response Headers Inspector lets you do the first part without installing anything: paste a URL and see the cache and security headers it returns. If you want to know when a page starts failing rather than just how it caches, the Website Uptime Monitor watches availability, and the rest of our free toolkit is on the Pingfloat homepage.

A short checklist to run through:

  1. Do versioned assets have max-age=31536000, immutable, and does the filename change on every deploy?
  2. Does HTML have either no-cache with a validator or a deliberately short max-age?
  3. Are personalized responses marked private or no-store?
  4. Is the CDN adding or overriding any header you didn't expect?
  5. Does a repeat conditional request return 304 and not 200?

Common mistakes

  • Using no-cache to mean "don't store". It stores. Use no-store.
  • immutable on unversioned files. Users stay on the old file until expiry or a manual cache clear.
  • no-store everywhere. You lose caching entirely and pay for it in load and latency.
  • No validator on revalidated content. Without an ETag or Last-Modified, a stale response can't be revalidated cheaply and gets downloaded in full.
  • Public caching of personal data. If a response differs per user, mark it private or no-store.
  • Trusting request directives. A client's Cache-Control: no-cache is advisory, so your origin policy must stand on its own.

FAQ

What does Cache-Control: max-age mean?

It says how many seconds a response stays fresh, counted from when it was generated. During that time, caches reuse it without contacting your server. After it passes, the response is stale and has to be revalidated or fetched again.

What does 304 Not Modified mean?

It means the stored copy is still valid. The browser sent a conditional request with If-None-Match or If-Modified-Since, and the server found no change, so it replied with headers only and no body. The browser then uses what it already has.

Should I use ETag or Last-Modified?

Use ETag if you can, since it tracks the content exactly while Last-Modified only tracks a timestamp. Sending both is fine. If a request carries both validators, the server checks If-None-Match first.

Is no-cache the same as no-store?

No. no-cache lets a cache keep a copy but forces revalidation before each reuse. no-store forbids keeping the response at all, and it is the one to use for sensitive content.

When should I use s-maxage?

Use it when your CDN or proxy should cache a response for a different time than browsers do. Shared caches follow s-maxage, while browsers follow max-age.

Sources