HTTP Caching: Freshness, Validation, and Shared Caches

LESSON

HTTP Protocol and Content Delivery

008 25 min intermediate

HTTP Caching: Freshness, Validation, and Shared Caches

The core idea: HTTP caching is a reuse contract: response headers tell browsers, CDNs, and proxies whether a stored response can be reused, must be revalidated, or must not be stored at all.

Core Insight

Imagine the checkout system now has three HTTP responses in flight. A product image is the same for everyone and changes rarely. A payment attempt status belongs to one logged-in user and may change every few seconds. A billing statement contains personal data that should disappear from intermediate storage as quickly as possible. All three may return 200 OK, all three may have JSON or bytes, and all three may pass unit tests. They should not have the same cache policy.

Caching is powerful because the fastest request is the one that does not reach the origin. A browser can reuse a response from disk. A CDN can answer near the user. A shared proxy can absorb repeated traffic. But caches are also independent actors. Once a response is stored, another component may decide to serve it later, perhaps to a different request, under rules written in headers. If those rules are vague, the system can serve stale state, leak personalized data, or overload the origin with pointless revalidation.

The previous lesson introduced validators such as ETag and Last-Modified. Validators answer, "Is my stored representation still current?" Caching adds another question before that: "Am I allowed to reuse the stored representation without asking the origin at all?" Freshness, validation, and privacy rules combine to answer it.

The trade-off is latency reduction versus stale or personalized data leakage. Aggressive caching lowers latency and origin cost. Conservative caching protects correctness and privacy. The work is to make that trade-off explicit per response type, not to turn caching on or off globally.

The Cache Decision in Small Pieces

A cache stores a response under a cache key. At minimum, that key includes the URI. If Vary is present, selected request headers also matter. A cache then decides whether the stored response can satisfy a later request.

The decision has four basic questions:

Can this response be stored?
Does this later request match the stored response?
Is the stored response still fresh?
If stale, can it be revalidated instead of downloaded again?

Cache-Control is the main response header for answering those questions. Consider a public product image:

HTTP/1.1 200 OK
Content-Type: image/webp
Cache-Control: public, max-age=86400
ETag: "product-842-hero-v12"

public says shared caches may store the response. max-age=86400 says it is fresh for 86400 seconds from the response time. During that freshness lifetime, a browser or CDN can serve it without contacting the origin. After it becomes stale, the cache can revalidate with the ETag from the previous lesson.

Now compare a logged-in payment status response:

HTTP/1.1 200 OK
Content-Type: application/vnd.shop.payment.v2+json
Cache-Control: private, max-age=0, must-revalidate
ETag: "payment-pay_901-v18"

{"id":"pay_901","status":"pending"}

private means the response is intended for a single user and must not be stored by shared caches. A browser cache may store it. max-age=0 means it is immediately stale. must-revalidate says a cache must check with the origin before reusing a stale copy. This policy can still be useful: the browser may keep a local copy and use If-None-Match to get a cheap 304 Not Modified, but a CDN should not serve this payment status to other users.

Finally, compare a billing statement download:

HTTP/1.1 200 OK
Content-Type: application/pdf
Cache-Control: no-store

no-store is the strongest everyday instruction here. It says caches should not store the response at all. Use it for highly sensitive responses, not as a generic substitute for thinking. no-store protects privacy, but it also removes useful reuse and revalidation.

Fresh Is Not the Same as Correct Forever

Freshness is a cache-local permission to reuse a stored response. It is not a claim that the origin state has not changed.

If a product image response has max-age=86400, a CDN can serve it for a day without checking the origin. If the design team replaces the image ten minutes later using the same URL, the CDN may still serve the old image until the response becomes stale or someone purges the cache. That is not a cache bug. It is the contract you wrote.

This is why cacheable assets often use versioned URLs:

/assets/product-842-hero.v12.webp
/assets/product-842-hero.v13.webp

With a versioned URL, a long freshness lifetime is safer because new content gets a new address. The old response can remain cached without hiding the new version from clients that request it. For mutable API resources, versioned URLs are usually not the right shape, so shorter freshness and validators become more important.

Freshness also interacts with status codes. A cache can store some successful responses, redirects, and even selected error responses under HTTP rules and cache policy. That can be useful for absorbing repeated 404 requests for missing assets. It can be harmful if a temporary 503 is cached too aggressively. The cache policy should fit the operational meaning of the response, not just its endpoint.

Shared Caches Need Stricter Evidence

A private cache serves one user. A browser cache is the normal example. A shared cache serves multiple users. CDNs, reverse proxies, gateway caches, and corporate proxies are shared caches.

The danger is not that shared caches are bad. The danger is that they cannot infer your data model. They see requests, response headers, status codes, and bodies. If a response contains user-specific data, tenant-specific data, authorization-dependent data, language-specific text, or negotiated media types, the cache policy and key must say so.

Suppose the origin returns this response for a logged-in user:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60

{"user":"alice","cart_items":3}

If a CDN stores this under /cart/summary, Bob may later receive Alice's cart summary. The CDN did exactly what it was told: the response was public and fresh for sixty seconds. The bug is the boundary contract.

The safer policy is usually:

Cache-Control: private, max-age=0, must-revalidate
Vary: Accept, Accept-Language

This says a browser may keep a local copy but shared caches must not store it. Vary still matters for the browser and for any allowed cache because representation dimensions can change the body. Do not use Vary: Cookie as a casual escape hatch on high-traffic responses; cookies are often large and variable, so they can destroy cache hit rates. Prefer designing clear public resources and clear private resources.

Some systems intentionally cache authenticated or personalized content at the edge. That is an advanced design, not the default. It requires a key that includes the correct user, tenant, authorization, or segment identity, plus strict purge and observability rules. The lesson's practical default is simple: if a shared cache cannot safely prove who the response is for, do not let it store the response.

Worked Path: Three Responses Through a CDN

Trace three requests through the same CDN:

client -> CDN/shared cache -> origin API

First request: public product metadata.

GET /products/842/summary
Accept: application/json

The origin returns:

Cache-Control: public, max-age=300
ETag: "product-842-summary-v44"
Vary: Accept

The CDN stores the JSON variant. For the next five minutes, matching requests can be served from the CDN without origin traffic. After that, the CDN can revalidate with If-None-Match. If the origin returns 304, the CDN refreshes the stored metadata without downloading the body again. This is the high-value path: lower latency, lower origin load, and bounded staleness.

Second request: payment attempt status.

GET /payment-attempts/pay_901
Authorization: Bearer ...
Accept: application/vnd.shop.payment.v2+json

The origin returns:

Cache-Control: private, max-age=0, must-revalidate
ETag: "payment-pay_901-v18"
Vary: Accept

The CDN should not store it because it is private. The browser may store and revalidate it. The user still gets efficient local validation, but the shared cache does not become a cross-user data leak.

Third request: billing statement PDF.

GET /billing/statements/2026-06.pdf
Authorization: Bearer ...

The origin returns:

Cache-Control: no-store

No cache should store it. The user pays more latency if they download it again, but the privacy trade-off is explicit.

These three outcomes use the same HTTP machinery. The difference is not the framework, CDN vendor, or route name. It is the response contract.

Operational Signals and Failure Modes

Failure: using no-cache when you mean no-store. no-cache does not mean "do not store." It means a stored response must be revalidated before reuse. For sensitive data that should not be stored at all, use no-store.

Failure: caching personalized responses as public. This is the classic shared-cache leak. Watch for Set-Cookie, Authorization, user IDs, tenant IDs, and account-specific fields on responses marked public.

Failure: forgetting Vary on negotiated content. If Accept or Accept-Language changes the body, a cache key that ignores those headers can serve the wrong variant. This connects directly to the content negotiation lesson.

Failure: long freshness on mutable URLs. Long max-age is excellent for versioned assets. It is dangerous for mutable API state unless stale data is acceptable. If operators constantly purge a resource to make it correct, the freshness policy probably does not match the product behavior.

Useful telemetry includes cache hit ratio, origin request rate, Age header samples, 304 rates, CDN status headers, stale response complaints, and incident traces where a user saw another user's data. A good cache policy should be visible in those signals. If nobody can tell whether a response came from browser, CDN, or origin, debugging will be slow.

Treat cache headers as deployable behavior. Review them when the data model, personalization rules, or CDN configuration changes.

Close the lesson and classify five responses from an API you know: public and long-lived, public but short-lived, private and revalidated, no-store, or not cacheable yet because the representation contract is unclear. For each one, write the Cache-Control, ETag or no validator decision, and Vary headers you would expect.

Connections

Conditional requests gave the mechanism for revalidation. Caching decides when revalidation is needed and who is allowed to store the response in the first place.

The next lesson on cookies adds another boundary: browsers automatically attach cookie state to requests. That automatic credential transport is one reason shared-cache policy must be explicit around personalized responses.

Resources

Key Takeaways

PREVIOUS Conditional Requests: ETags, Last-Modified, and Precondition Logic NEXT Cookies, SameSite, and Session Transport