1 Overview
Cache-Control headers are the primary control plane for HTTP caching, yet they are often set once and forgotten. A cache that stores too aggressively can serve stale or personalized content, while a cache that stores too little forces every request to the origin. Both outcomes look like performance problems, but they have opposite causes. The goal is not to maximize caching; it is to align freshness, revalidation, and cacheability with how each response actually changes.
The most common mistake is treating all responses the same. Static assets, API payloads, HTML documents, and authenticated responses have different change rates and different privacy requirements. A single global header cannot serve them well. When teams tune Cache-Control, they usually start with max-age and s-maxage, then discover that Vary, ETag, and revalidation directives matter just as much. The result is a cache that either helps or hurts depending on request patterns.
This article focuses on the mechanism, the trade-offs, and the measurement loop. It avoids vendor-specific behavior and performance guarantees. Instead, it explains how directives interact, where common advice breaks down, and how to verify that a change actually improved latency, origin load, or cost.
2 Where the common advice breaks down
A frequent recommendation is to set long max-age values on everything static and short values on everything dynamic. That heuristic fails when a response is static in content but personalized in delivery. For example, an HTML shell may be identical for all users, but a Set-Cookie header or a Vary: Cookie directive can make it uncacheable or force per-user storage. Similarly, an API response may be cacheable for anonymous users but must not be stored for authenticated ones. Blanket rules ignore these distinctions.
Another breakdown is the assumption that revalidation is cheap. Conditional requests still travel to the origin or a shared cache, and they still consume connections, CPU, and sometimes backend work. If every response uses no-cache or must-revalidate, the cache becomes a pass-through with extra steps. Conversely, if responses use immutable or very long max-age without versioned URLs, clients may keep stale content far longer than intended. The advice to cache aggressively is only safe when the URL changes when the content changes.
A third issue is the confusion between browser caches and shared caches. Directives like private, public, s-maxage, and proxy-revalidate apply differently depending on the cache layer. A header that works well for a browser may be ignored or misinterpreted by an intermediary. Without separating these layers, teams often fix one cache and break another.
3 How the mechanism actually works
Cache-Control is a set of directives that tell a cache whether it may store a response, how long it remains fresh, and what must happen after freshness expires. max-age sets the freshness lifetime in seconds for a browser or private cache. s-maxage overrides max-age for shared caches such as proxies and edge servers. When a response is fresh, the cache can serve it without contacting the origin. When it is stale, the cache may revalidate using ETag or Last-Modified, or it may be forbidden from serving it at all.
Revalidation is a conditional request. The cache sends If-None-Match with the stored ETag or If-Modified-Since with the stored Last-Modified value. If the origin responds with 304 Not Modified, the cache refreshes the stored response and serves it. If the origin responds with 200 and a new body, the cache replaces the stored response. Directives like no-cache do not mean do not store; they mean revalidate before every use. no-store means do not store at all. must-revalidate prevents serving stale content after expiration, while stale-while-revalidate and stale-if-error allow a cache to serve stale content while refreshing or when the origin is unavailable.
Vary is equally important. It tells caches which request headers affect the response. If Vary is missing or too broad, caches may serve the wrong variant or fragment the cache into many near-duplicate entries. Correct Vary usage keeps variants distinct without exploding storage.
4 Trade-offs you are really making
The central trade-off is freshness versus origin load. Longer freshness lifetimes increase hit rate and reduce origin traffic, but they also increase the window in which users may see outdated content. Shorter lifetimes improve freshness but push more requests to the origin, raising latency and cost. There is no universal correct value; it depends on how often the content changes and how costly staleness is for the user experience.
A second trade-off is storage versus revalidation. Storing many variants, such as per-language or per-encoding responses, can improve hit rate for diverse audiences, but it consumes cache capacity and may evict other useful entries. Revalidating instead of storing variants reduces storage pressure but adds conditional requests. The right balance depends on request diversity and the cost of a miss.
A third trade-off is simplicity versus precision. A single Cache-Control header for a whole site is easy to reason about but almost always wrong for some responses. Per-route or per-content-type headers are more accurate but require coordination between application code, reverse proxies, and edge configuration. Finally, privacy and security constrain caching. Responses with user-specific data, authentication tokens, or sensitive headers must not be stored in shared caches. Getting this wrong can leak data, so cacheability decisions must be made deliberately, not by default.
5 Implementing it correctly
Start by classifying responses into a few clear groups: immutable static assets, cacheable public content, cacheable private content, and uncacheable content. For immutable assets, use versioned URLs and a long max-age with immutable. For public content that changes occasionally, use a moderate max-age and s-maxage, plus stale-while-revalidate to smooth refreshes. For private content, use private with a short max-age and require revalidation. For uncacheable content, use no-store and avoid storing sensitive data.
Next, ensure validators are present. ETag or Last-Modified values let caches revalidate efficiently. If the origin cannot generate strong validators, weak validators or no validators may force full responses. Set Vary correctly for responses that depend on request headers such as Accept-Encoding, Accept-Language, or Authorization. Avoid Vary: * unless the response truly cannot be cached. Remember that Vary: Cookie often makes a response effectively uncacheable in shared caches, which may be correct for personalized pages but wrong for anonymous ones.
Finally, align layers. A CDN or reverse proxy may add, remove, or override Cache-Control headers. Browser caches and shared caches interpret directives differently. Document the intended behavior for each layer and test with real requests. Use explicit directives rather than relying on defaults, because defaults vary and are rarely what you want for a mixed workload.
6 Measuring whether it helped
Measurement should focus on cache behavior and user-visible latency, not just origin CPU. Track hit rate, miss rate, and revalidation rate by content group. A rising hit rate with stable or improved latency suggests the change helped. A rising hit rate with increased staleness complaints or data freshness issues suggests the TTL is too long. Conversely, a falling hit rate with lower origin load may mean the cache is doing less work but the origin is handling more, which is usually not an improvement.
Look at conditional requests separately. If revalidation volume is high, the cache is not absorbing enough traffic. If 304 responses dominate, the origin is doing work just to confirm freshness. Both patterns can be optimized by adjusting freshness lifetimes or improving validators. Also measure cache storage and eviction. High eviction rates on useful content indicate that the cache is too small or that too many variants are being stored.
Finally, measure end-to-end latency percentiles and error rates. A cache change that improves average latency but worsens tail latency may not be a win. Stale-if-error can improve availability during origin failures, but it can also mask problems. Use controlled experiments or staged rollouts where possible, and compare periods with similar traffic. The goal is to confirm that the cache is reducing work without serving content that is too old or too private.
7 Frequently asked questions
Does a longer max-age always improve performance? No. It improves hit rate and reduces origin load, but it also increases the risk of stale content. If the URL does not change when the content changes, a long max-age can serve outdated responses to users. Use versioned URLs or shorter lifetimes when freshness matters.
What is the difference between no-cache and no-store? no-cache allows a cache to store the response but requires revalidation before each use. no-store forbids storing the response at all. Use no-store for sensitive or user-specific data that must never be retained by intermediaries.
Should I use s-maxage or max-age? Use both when browser and shared cache behavior should differ. s-maxage applies to shared caches and overrides max-age for them, while max-age applies to private caches. This lets you keep browser caching short while allowing edge caches to hold content longer, or the reverse.
How does Vary affect hit rate? Vary tells caches which request headers create distinct variants. A broad Vary, such as Vary: *, or a header with many values, such as Cookie, can fragment the cache and lower hit rate. Use the narrowest Vary that correctly distinguishes responses.
When should I use stale-while-revalidate? Use it when serving slightly stale content is acceptable while the cache refreshes in the background. It improves perceived latency and reduces origin spikes, but it requires that the content tolerate a short period of staleness.