Caching is one of those subjects where a little knowledge is genuinely dangerous. Setting max-age=3600 feels responsible — your server is no longer hammered by repeat requests — but users still report seeing stale content an hour later, or much longer, depending on where those responses landed. The problem is rarely the directive itself. It is usually a misunderstanding of the cache hierarchy, a conflation of no-cache with no-store, or an absent cache-busting strategy that makes long TTLs safe to use in the first place.

The Cache Hierarchy

Before reasoning about individual headers, it helps to have a clear model of where caches live. An HTTP response can be stored and reused at several layers:

Browser cache — private to the user, stored on disk. Fastest possible hit: zero network round-trips. Controlled by Cache-Control response headers (and, historically, Expires).

Shared cache / CDN — sits between many users and the origin. A CDN edge node in Frankfurt may serve thousands of users from a single cached copy. Controlled by the same Cache-Control response headers, though CDN vendors sometimes layer proprietary directives on top.

Origin server — your application. Only hit when everything upstream misses or decides to revalidate.

This layering matters because a directive like private tells shared caches to stand down while still permitting browser caching. Get the target wrong and you may be caching authenticated responses at the CDN edge, or busting caches more aggressively than necessary.

Cache-Control Directives That Actually Matter

The Cache-Control header accepts a comma-separated list of directives. Most real-world use requires fewer than a dozen of them.

max-age and s-maxage

max-age=N sets the maximum time in seconds a response can be considered fresh. s-maxage=N does the same thing, but only for shared caches — it overrides max-age at the CDN while leaving browser behavior untouched. This is useful when you want the CDN to cache aggressively but browsers to revalidate more frequently (or vice versa).

no-cache vs. no-store — the distinction that trips everyone up

These two directives sound similar and are routinely confused, but they produce opposite behaviours.

no-cache does not mean “do not cache.” It means the cached response must be revalidated with the origin server on every use. The response is still stored; the cache is simply not permitted to serve it as fresh without checking in first. If the origin confirms the content has not changed (HTTP 304), the cached copy is served without re-downloading the body. Network round-trips still happen, but bandwidth is spared.

no-store means the response must not be stored anywhere, by any cache. No disk writes, no memory caching, nothing. Use this for genuinely sensitive data — authenticated responses containing personal information, session payloads, financial records. It is a strong directive; reach for it deliberately.

A common mistake: applying no-store to an HTML page because you want users to always see the latest version. The right tool is no-cache with validation headers. no-store throws away the ability to serve a 304 and always forces a full download.

public and private

public explicitly signals that a response can be stored by shared caches, even if it would normally be considered uncacheable (for example, a response to a request with an Authorization header). private restricts caching to the browser; CDNs and other intermediaries must not store the response.

The default when neither is specified is context-dependent. In practice, if you are serving through a CDN, be explicit.

immutable

immutable tells the browser that a cached response will never change for its entire freshness lifetime. It suppresses the conditional revalidation request that some browsers issue on page reload even when max-age has not expired. This directive is particularly valuable for hashed static assets — JavaScript bundles, CSS files, fonts — where the filename changes with every build, making the content genuinely immutable for a given URL. Browser support is solid across modern browsers.

stale-while-revalidate

stale-while-revalidate=N allows a cache to serve a stale response while revalidating in the background. The user gets an immediate (possibly slightly outdated) response; the fresh copy arrives for the next request. This directive trades strict freshness for perceived performance. It is well-suited to API responses or HTML pages where data changes frequently but a few seconds of staleness is acceptable. The RFC 5861 extension is now widely supported in CDNs and modern browsers.

ETag and Last-Modified Validation

Conditional requests are the mechanism by which a cache checks whether its stored copy is still valid. Two response headers enable this:

ETag — an opaque identifier for the current version of the resource. The value is typically a hash of the content or a version string. On revalidation, the browser sends If-None-Match: <etag> and the server responds with either 200 OK (new content) or 304 Not Modified (no change, no body).

Last-Modified — a timestamp of the resource’s last change. The browser sends If-Modified-Since: <date> on revalidation. Less precise than ETags — timestamps have one-second granularity, which can cause false positives — but still useful when ETags are not available.

ETags come in two flavours. A strong ETag ("abc123") guarantees byte-for-byte identity. A weak ETag (W/"abc123") indicates semantic equivalence — the content is equivalent for the purposes of serving but may differ in minor ways (whitespace, gzip variation). Strong ETags are required for range requests; weak ETags are sufficient for most cache validation use cases.

The 304 response is one of HTTP’s underappreciated optimisations. A 100KB JavaScript file costs nothing to revalidate if the ETag matches — the browser skips the download entirely and uses its cached copy. Combine no-cache with a strong ETag and you get guaranteed freshness with minimal bandwidth cost.

Cache Busting: Content Hashing vs. Query Parameters

Long TTLs are desirable — they reduce origin load and latency. The problem is deploying updates. If a browser has cached app.js for a year, a new deployment is invisible until the cache expires. Cache busting solves this by changing the URL when the content changes, forcing a fresh fetch.

Content hashing in filenames — the right approach

Modern build tools (Vite, webpack, esbuild, Rollup) support content-addressed output: the hash of the file’s contents becomes part of the filename. app.js becomes app.8f3c2a1d.js. The HTML page references the hashed filename. When the JavaScript changes, the hash changes, the filename changes, and the browser treats it as a new resource.

This approach lets you set Cache-Control: public, max-age=31536000, immutable on all static assets — a full year, marked immutable. The CDN caches aggressively, browsers cache aggressively, and deploys are instant because new filenames are new URLs. The HTML page itself, which contains the references to those hashed filenames, is served with a short TTL or no-cache, so it always reflects the latest build.

Query parameters — the wrong approach and why

Appending a version query parameter — app.js?v=2.1.4 — seems equivalent but is not. Some CDNs and proxies strip or ignore query strings when constructing cache keys by default. A response cached for app.js?v=2.1.3 may be served for app.js?v=2.1.4 if the CDN was not explicitly configured to vary on query strings. More subtly, intermediate caches (ISPs, corporate proxies) have historically ignored query strings entirely. Filename-based hashing avoids this entire class of problem: the path changes, which no cache layer can ignore.

What to Cache and for How Long

Hashed static assets (JS, CSS, images with cache-busted URLs)

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

One year, immutable, public. Safe because the URL changes on every content change.

HTML pages

Cache-Control: no-cache
ETag: "v3.2.1"

Or, if you are comfortable with brief staleness:

Cache-Control: public, max-age=60, stale-while-revalidate=3600

HTML pages reference hashed assets, so they must stay fresh. no-cache with a strong ETag gives you validation without unnecessary downloads. Short max-age with stale-while-revalidate gives you performance with an acceptable staleness window.

API JSON responses

This depends heavily on the nature of the data. For public, non-personalised data:

Cache-Control: public, max-age=300, stale-while-revalidate=60

For authenticated or personalised responses:

Cache-Control: private, no-cache
ETag: "user-42-data-hash"

private keeps the response out of shared caches. no-cache with an ETag still allows efficient revalidation — the browser avoids re-downloading a payload the user already has.

Authenticated content and sensitive data

Cache-Control: no-store

Session payloads, account pages, checkout responses — these must not be stored anywhere. no-store is the correct directive. Do not use it indiscriminately (it prevents the 304 optimisation), but do use it for content that genuinely must not persist.

The Vary Header

Vary instructs caches to maintain separate cached copies based on the values of specified request headers. The most common use is Vary: Accept-Encoding, which tells caches that a gzip-encoded response and a Brotli-encoded response are different resources and must be stored separately.

Vary: Accept-Language is sometimes used for language-negotiated responses, though in practice this fragments the CDN cache heavily. Vary: Cookie effectively prevents CDN caching for any request that includes a cookie, which is often broader than intended — many sites send a cookie on every request (analytics, A/B testing), making CDN caching impossible for the whole site unless cookie handling is more surgical.

If you are debugging why your CDN is not caching a response, inspect Vary. A broad Vary: Cookie is a common culprit.

Debugging Cache Behaviour

The Age response header tells you how many seconds a cached response has been sitting in a shared cache. If you request a resource and get Age: 3600, you are receiving a copy that has been at the CDN edge for an hour. An Age of 0 means the response was just fetched from origin.

X-Cache (and variations like CF-Cache-Status on Cloudflare, X-Served-By on Fastly) indicate whether the response was a cache hit or miss. These headers are not standardised but are consistently present on major CDN platforms.

The Chrome DevTools network panel shows the cache source for each resource — distinguishing between a disk cache hit, a memory cache hit, and a network request. For testing cache directives in isolation, use a private window or disable the browser cache entirely via DevTools while working — otherwise browser caching can mask CDN or server-side behaviour.

Understanding how the browser prioritises resource loading is a related dimension of this problem: the caching layer determines whether a resource is fetched at all, while loading priority determines when it is fetched relative to other resources.

For teams working with web fonts, the caching model is particularly consequential — font files are large, cross-origin, and loaded on nearly every page. A max-age=31536000, immutable strategy on font files with versioned filenames is almost always the right call.

The HTTP/1.1 caching specification (RFC 9111) is the authoritative reference for cache semantics. MDN’s HTTP caching guide provides a practical and well-maintained summary. When behaviour differs from expectations, the spec is the ground truth — browser and CDN implementations sometimes lag or diverge, but knowing what the spec requires makes it possible to distinguish a bug from a misunderstanding.