1 Overview
Caching an HTTP response is a bet that the same request will recur and that the answer will still be correct when it does. That bet pays off when responses are expensive to produce, when the same URLs are requested repeatedly, and when the content does not change between those requests. It loses when every request is unique, when the response depends on who is asking, or when correctness demands that clients always see the newest state. The decision is therefore less about whether caching is good and more about whether a specific response is a good candidate.
The first question is cacheability: can this response be stored and reused at all, and by whom? A public response can be held by shared intermediaries, while a private one belongs only to a single user's client. The second question is keying: what inputs actually change the response body? If the answer depends on a cookie, an authorization header, a device class, or a query parameter, the cache key and the Vary header must reflect that, or the cache will serve the wrong bytes to the wrong caller.
The third question is lifetime and invalidation. A response that changes every few seconds needs a short lifetime or active purging; a fingerprinted asset that never changes can be reused almost indefinitely. Most caching failures trace back to answering these three questions loosely, then discovering the mismatch only when users report stale or incorrect content.
2 Where the common advice breaks down
The popular shorthand, cache static assets aggressively and never cache dynamic pages, is a useful starting point that collapses under real traffic. Many responses are neither fully static nor fully personal. A product page may be identical for anonymous visitors and different for logged-in ones, a search result may vary by a few query parameters, and an API response may be shared across a whole tenant. Treating these as binary forces you to either over-cache and leak data or under-cache and pay full origin cost on every request.
Another weak assumption is that a high hit rate is always the goal. Hit rate is a symptom, not an objective. You can raise it by caching responses that should not be shared, by lengthening TTLs past the point of correctness, or by ignoring Vary and serving one variant to everyone. Each of those inflates the metric while degrading trust. A cache that is right more often than it is fast is usually worth more than one that is fast and occasionally wrong.
A third breakdown is treating TTL as a tuning knob rather than a correctness boundary. Shortening a TTL does not make an incorrect cache correct; it only narrows the window in which the error appears. If a response must never be stale, the fix is revalidation or explicit invalidation, not a smaller number. The common advice also tends to ignore request distribution: caching helps when a small set of keys absorbs a large share of traffic, and helps little when keys are nearly unique.
3 How the mechanism actually works
An HTTP cache stores a response against a key, usually derived from the request method and target URI, and returns it for later requests that match. Freshness is governed by explicit directives such as max-age or by heuristics when none are present. While a stored response is fresh, the cache can serve it without contacting the origin. Once it becomes stale, the cache may revalidate using a validator such as an entity tag or last-modified time, and the origin can reply that nothing changed, allowing reuse without resending the body.
Sharing rules matter as much as lifetime. A response marked public can be stored by shared caches, while private restricts storage to a single user's client. Responses to requests with authorization headers are not shared by default unless explicitly permitted. The Vary header tells caches which request headers participated in selecting the response, so a cache can keep separate variants for different encodings, languages, or device classes. Omitting Vary when the response actually depends on a header is one of the most common causes of cross-user contamination.
Invalidation closes the loop. Because caches are distributed and clients hold copies you cannot reach, the reliable technique is to change the URL when the content changes, typically by embedding a content hash or version in the path. That makes each version a distinct, immutable resource with a long lifetime, and it makes rollback a matter of pointing at the previous URL. Explicit purge is useful for mutable URLs, but it only reaches caches you control; intermediate and client caches may still hold stale copies until their own freshness expires.
4 Trade-offs you are really making
Every caching decision trades freshness against origin cost and latency. Reusing a response for longer reduces the number of origin requests, which lowers compute, database, and egress load and typically improves tail latency for users. The price is a wider window in which a change has not propagated. The right balance depends on what a stale response costs: a stale image is cosmetic, a stale price or permission check can be a real incident.
You are also trading simplicity against precision. A single long TTL with versioned URLs is easy to reason about and hard to get wrong, but it requires build and deploy discipline to change URLs when content changes. Fine-grained purging gives faster propagation for mutable URLs, but adds operational surface, requires cache-aware tooling, and can behave differently across layers you do not own. More variants, driven by Vary or by keying on many headers, improve correctness but fragment the cache and lower the effective hit rate.
Finally, you are trading shared efficiency against isolation. Shared caching multiplies the benefit of one origin response across many users, which is where most of the savings come from. But sharing is only safe when the response truly does not depend on the requester. Personalization, entitlements, and per-user data push you toward private caching or no caching at all, which shifts cost back to the origin and to the client. Naming which of these trade-offs you are accepting is more useful than arguing about a specific TTL value.
5 Implementing it correctly
Start by classifying each response by who may store it and how long it stays correct. Public, rarely changing resources get long lifetimes and versioned URLs. Public but frequently changing resources get short lifetimes plus revalidation. Private or user-specific responses get private caching or explicit no-store, and anything containing secrets should not be stored in shared layers at all. Write these decisions into the response headers at the point where the response is generated, so the policy travels with the payload rather than living only in proxy configuration.
Next, make the cache key honest. Enumerate the request inputs that change the body, then express them through the URL where possible and through Vary where not. Prefer normalizing query parameters, stripping tracking parameters, and ordering them consistently so equivalent requests collapse to one key. Be conservative with Vary on headers that have high cardinality, because each distinct value creates another stored variant and can quietly destroy your hit rate.
Then choose an invalidation path you can actually operate. Versioned or content-hashed URLs are the most robust because they need no purge at all; a new deployment simply references new names. For mutable URLs, define what triggers a purge and who owns it, and accept that purge is best-effort across caches you do not control. Add revalidation validators so stale entries can be confirmed cheaply instead of refetched in full. Finally, document the policy per route so future changes do not silently break assumptions about freshness or sharing.
6 Measuring whether it helped
Measure cache effectiveness in terms of origin work avoided and user-visible latency, not just hit ratio. A rising hit rate is meaningless if it comes from caching responses that should not be shared, or if the misses that remain are the expensive ones. Segment the metrics by route, by response class, and by cache layer, because a healthy edge hit rate can hide a struggling origin or a fragmented private cache. Track bytes served from cache versus bytes fetched, since body size often matters more than request count for egress cost.
Watch staleness directly. Instrument how long after a content change users continue to see the old version, and compare that against the tolerance you intended. If you cannot observe propagation delay, you cannot claim the cache is correct. Also monitor variant explosion: a growing number of stored keys per URL usually signals that Vary or query parameters are fragmenting the cache more than expected. Correlate that with hit rate to see whether correctness is being bought at an unreasonable cost.
Finally, run changes as controlled experiments where possible. Adjust one dimension, such as TTL or key normalization, and observe origin request volume, tail latency, and error or staleness indicators together. A change that improves hit rate while increasing stale-content reports is not an improvement. The goal is a defensible balance you can explain, not a single number that looks good in a dashboard.
7 Frequently asked questions
Should I cache API responses the same way I cache static assets? Not with the same lifetimes or sharing rules. APIs often return data that depends on identity, tenant, or rapidly changing state, so they usually need shorter freshness, explicit revalidation, and careful attention to authorization headers. Static assets are typically immutable once published and can be reused for long periods under versioned URLs.
Is a longer TTL always better for performance? Longer lifetimes reduce origin traffic and improve latency, but they also widen the window for stale or incorrect content. The useful question is how long the response stays correct, not how long it could be stored. When correctness is uncertain, revalidation or versioned URLs are safer than simply extending the lifetime.
What is the most common caching mistake? Serving one variant to users who should have received different ones, usually because the response depends on a header or cookie that the cache key and Vary do not reflect. This produces cross-user contamination that is hard to notice until it affects something sensitive.
How do I invalidate content that changes unpredictably? Prefer changing the URL when content changes, since that needs no purge and works across caches you do not control. Where URLs must stay stable, combine short freshness with revalidation and explicit purge for the layers you operate, and accept that some clients may hold stale copies until their own expiry.