Cache-Control Header Guide: no-cache vs no-store

By Anders Vik ·

Cache-Control Header Explained: max-age, no-cache and no-store

You ran a page through a header checker, or you opened DevTools and looked at the Network tab, and there it was: a Cache-Control header you didn't write, sitting on a response you own. Maybe it says no-cache and you assumed that meant "never cache this." Maybe it says nothing about max-age at all. Either way, you're now wondering whether the value in front of you is doing its job, or quietly working against you.

I've spent enough late nights chasing "why is this page stuck on old content" tickets to know the answer is almost always the same header, misread. Cache-Control is short, it looks simple, and it is neither of those things once a CDN gets involved.

What the Cache-Control Header Actually Controls

Cache-Control is a response header. The origin server sends it, and three different audiences read it: the visitor's browser, any shared cache sitting between the browser and your server (a corporate proxy, an ISP cache), and a CDN edge node if you're running one. Each of those caches makes its own decision about whether to store your response and how long to trust it, and Cache-Control is the instruction set they're supposed to follow.

The header is a comma-separated list of directives. Some set a time limit (max-age), some set a permission (public, private), and some set a behavior (no-cache, no-store, must-revalidate). You can combine several in one header, and most real-world values do exactly that: Cache-Control: no-cache, private is a normal, correct thing to send on a logged-in dashboard page.

The current standard for all of this is RFC 9111, HTTP Caching, published by the IETF and the document that superseded the older RFC 7234. If a blog post or Stack Overflow answer contradicts RFC 9111, the RFC wins. It's the one document every browser vendor and CDN operator is implementing against.

How max-age is measured (and why the Age header matters)

The max-age directive sets a freshness window in seconds, counted from when the response left the origin server, not from when the browser happened to fetch it. RFC 9111 section 5.2.2.1 defines it plainly: the response is to be considered stale once its age is greater than the number of seconds you specified.

That distinction matters more than it sounds like it should. If a CDN edge node fetched your page an hour ago and has been serving it from cache since, a browser hitting that edge node now doesn't get a fresh hour of caching. It inherits whatever's left. That's what the Age response header is for: it tells every downstream cache how many seconds have already elapsed since the origin generated the response, so nobody double-counts the freshness window.

A cache control max age setting like max-age=86400 gives a resource a full day of freshness before any cache is required to check back with the origin. Set it too long on content that changes and visitors sit on stale pages with no idea why. Set it too short, or leave it off entirely, and you lose caching you could have had for free.

Per the HTTP Archive's Web Almanac caching chapter, a majority of responses that do carry a Cache-Control header still skip max-age entirely, which means they fall back to each cache's own guess about how long to keep them. That guess is rarely what you'd have chosen on purpose, and it's a pattern worth checking your own site against rather than assuming away.

Cache-Control: no-cache vs Cache-Control: no-store

This is the pair that causes the most support tickets, and the confusion starts with the name. "No-cache" sounds like it should mean "don't cache this." It doesn't.

Cache-Control: no-cache tells a cache it's allowed to store the response, but it must revalidate with the origin before reusing it for another request. RFC 9111 section 5.2.2.4 puts it precisely: a response marked no-cache "must not be used to satisfy any other request without forwarding it for validation." In practice, with a working ETag or Last-Modified value on the response, that revalidation is usually a fast 304 round trip, not a full re-download. MDN's HTTP reference states this outright and it's worth quoting because so many people get it backwards: no-cache does not mean don't cache.

Cache-Control: no-store is the directive people actually mean when they say "don't cache this." RFC 9111 section 5.2.2.5 is unambiguous: a cache "must not store any part of either the immediate request or the response." No copy, anywhere, not even a stale one waiting to be revalidated. This is what you want on a payment form response, a password-reset confirmation, or any page rendering session-specific tokens that should never persist in a shared or intermediary cache.

The practical rule I give people: if the content is fine to keep around as long as it's checked before reuse, use no-cache. If the content should never be written to disk anywhere outside your own server, use no-store. Sending both together, as in no-cache, no-store, is redundant.

no-store already forbids storage, so no-cache's revalidation instruction has nothing left to apply to. It won't break anything, but it's a sign the config was copied from somewhere without being read.

Public vs private: who's allowed to store the response

public and private answer a different question than no-cache and no-store. They're about which caches are allowed to hold the response at all, not about how long or under what condition.

Cache-Control: public tells any cache, shared or private, that it may store the response, per RFC 9111 section 5.2.2.9, "even if it would otherwise be prohibited." That's a green light for CDNs, corporate proxies, and browsers alike.

Cache-Control: private restricts storage to the end user's own browser cache. Section 5.2.2.7 is direct about it: a shared cache must not store the response, because it's intended for a single user. This is the directive that stops a CDN or corporate proxy from caching a response meant for one logged-in visitor and accidentally serving it to the next person who requests the same URL.

Here's where it gets dangerous rather than just inefficient. A cookie on a response does not, by itself, instruct any cache to treat that response as private. Google's web.dev security guidance is direct about this: a credentialed response cached without an explicit private directive (or a matching Vary: Cookie) can be replayed by a shared cache to a completely different, unauthenticated visitor. That's not a hypothetical, it's the exact failure mode behind the public_with_cookie warning our own checker flags, and it's the kind of bug that stays invisible until someone screenshots another user's account page and emails it to support.

The other directives worth knowing

A handful of other directives show up often enough to name, even without a full section each:

  • must-revalidate: once a response goes stale, a cache is forbidden from serving it again until it's been validated against the origin, even if the origin is temporarily unreachable. Pairs naturally with a short or zero max-age.
  • immutable: tells the browser this exact URL will never change, so don't even bother checking on reload. Only safe on content-hashed filenames, since if you ever do update the file at that same URL, browsers holding it as immutable won't know to ask.
  • stale-while-revalidate: lets a cache serve a slightly stale response immediately while it fetches a fresh one in the background. Good for content where a few extra seconds of staleness is a fair trade for speed.
  • s-maxage: overrides max-age specifically for shared caches like CDNs, letting you set one freshness window for the browser and a different one for the edge.

What to send for each type of page

Directive definitions are only useful once you can map them to the page in front of you. Here's the decision I'd make for each content type, and it matches the pattern web.dev's own HTTP Cache guidance recommends.

Page typeCache-Control valueWhy
HTML pages (unversioned URL)no-cacheContent can change anytime; every request should revalidate against the origin first
Hashed static assets (app.a1b2c3.js)max-age=31536000, immutableThe filename changes when the content does, so a year-long cache with no revalidation is safe
Logged-in or personalized pagesprivate, no-cacheKeeps it out of shared caches and forces revalidation in the browser's own cache
Highly sensitive responses (payment, auth tokens)no-storeNothing gets written to any cache, anywhere, ever
API responses (data that changes often)no-cache, private or a short max-ageDepends on how stale a client can tolerate; default to no-cache if unsure

Here's what that looks like as an actual Apache config block, using mod_headers, which is enabled by default on most managed and shared hosting but worth confirming with your host if you're not sure. This goes in your site's .htaccess file (or the virtual host config, if you have access to it) at the document root.

<IfModule mod_headers.c>
    # Hashed, versioned static assets: cache hard, skip revalidation
    <FilesMatch "\.[0-9a-f]{8,}\.(js|css)$">
        Header set Cache-Control "max-age=31536000, immutable"
    </FilesMatch>

    # HTML documents: always check back with the origin
    <FilesMatch "\.html?$">
        Header set Cache-Control "no-cache"
    </FilesMatch>

    # Logged-in area: private and revalidated, never shared
    <LocationMatch "^/account/">
        Header set Cache-Control "private, no-cache"
    </LocationMatch>
</IfModule>

Line by line: the <IfModule> wrapper stops the whole block from throwing a 500 error on the rare host where mod_headers isn't loaded. Each Header set Cache-Control line replaces any Cache-Control value your application or server was already sending for that pattern, rather than appending a duplicate. The FilesMatch blocks key off the filename pattern; the LocationMatch block keys off the URL path instead, which is the right tool when the file extension doesn't tell you what you need to know.

To verify it worked, run curl -I https://yourdomain.com/path/to/file and check the Cache-Control line in the response, or run the URL through httpcheck.tools and look at the caching section of the report. If the value you set isn't showing up, the most common cause is a duplicate Cache-Control header being added further down the response chain, by your CMS or by a CDN rewrite rule, after Apache's already set it. Our own checker flags that as a duplicate_header issue specifically because it's easy to miss by eye. If you'd rather write the rule for a different platform, htaccess.tools has a generator for the equivalent Apache syntax across a wider range of scenarios.

Why your CDN ignores the Cache-Control header you set

If you're running a CDN in front of your origin, you'll hit a second layer of behavior that has nothing to do with the RFC and everything to do with vendor defaults. Cloudflare's own documentation states that when no Cache-Control or Expires header is present at all, it applies its own default edge TTLs: 120 minutes for 200, 206, and 301 responses, 20 minutes for 302 and 303, and 3 minutes for 404 and 410. That's Cloudflare guessing on your behalf, exactly the heuristic-caching scenario a missing header invites.

The flip side trips people up just as often. That same Cloudflare page is explicit that it will not cache a response at all when it sees private, no-store, no-cache, or max-age=0. So "my CDN isn't caching this page even though I set Cache-Control" isn't usually a bug report, it's the CDN doing exactly what you told it to do. The fix, if you wanted edge caching after all, is to change the directive you're sending, not to file a support ticket.

Worth separating in your head: browser caching and CDN edge caching read the same header but often land on different decisions, and a header checker showing you the raw value from the origin won't tell you which CDN behavior it's about to trigger.

The mistakes an HTTP header checker catches

Most Cache-Control problems fall into a short, recognizable list:

  • No Cache-Control header at all. The response falls back to heuristic caching in every browser and CDN, and you have no say in how long any of them decide to keep it.
  • no-store on static assets that should be cached. Forces a full re-download of every CSS and JS file on every single visit, which is slow for the visitor and needless load on your origin.
  • Unreasonably long max-age on content that changes. Visitors get stuck on old content until the cache expires, with no way to force a refresh short of a hard reload.
  • HTML cached for a long duration. Same problem as above, specifically on the page type where staleness is most visible to a normal visitor.
  • no-cache and no-store combined. Not dangerous, just redundant, and a sign the header was copy-pasted rather than reasoned through.
  • public sent alongside a Set-Cookie header. The public/cookie leak risk covered above; this is the one worth fixing first if you find it.
  • A redundant Expires header alongside Cache-Control. Cache-Control takes precedence in every modern cache, so the Expires value is dead weight, not a backup.

All seven of those map directly to issue codes our HTTP header checker reports on, along with the rest of what a response is telling browsers, proxies, and CDNs. If you haven't run your own pages through it, that's the fastest way to see whether any of this list applies to you right now rather than in theory. For the full header-by-header breakdown beyond caching alone, the HTTP headers explained guide covers the rest of what shows up in a typical response.

Frequently Asked Questions

What is the difference between no-cache and no-store?

no-cache allows a cache to store the response but requires it to revalidate with the origin server before reusing it for another request, usually a quick 304 Not Modified check. no-store forbids storing the response anywhere at all, in any cache, under any condition. Use no-cache for content that's fine to keep as long as it's checked first; use no-store for anything that should never persist outside your own server.

What does max-age mean?

max-age sets, in seconds, how long a response can be considered fresh before a cache must check back with the origin. The clock starts when the origin generated the response, not when a particular browser fetched it, which is why the Age header exists: it tells each downstream cache how much of that freshness window has already elapsed elsewhere in the chain.

What is Cache-Control public vs private?

public permits any cache, shared or private, including CDNs and corporate proxies, to store the response. private restricts storage to the end user's own browser and forbids shared caches from keeping a copy. Private is the correct choice for anything personalized or tied to a specific logged-in session, since a shared cache serving that response to a different visitor is a real data-leak risk, not a theoretical one.

What Cache-Control should HTML pages send?

For most HTML pages, send Cache-Control: no-cache, which lets browsers keep a copy but forces a revalidation check on every load, so content updates show up immediately without giving up the speed benefit of a cheap 304 response. If the page is behind a login, send private, no-cache instead so shared caches and CDNs stay out of it entirely.