HTTP Cache-Control: Choose a Policy You Can Defend
Choose HTTP cache policies for public pages, private data, and immutable assets. Learn how to test freshness, variants, and invalidation without guesswork.
HTTP Cache-Control is a product decision expressed as a response header. Before choosing a duration, decide who may reuse the response and what damage an old copy could cause. A stale article introduction and a stale account balance deserve different answers.
My preferred starting point is a short policy table owned by the application team. For every response class, record its audience, acceptable staleness, and invalidation mechanism. That makes a cache change reviewable without requiring everyone to reconstruct the behavior of a browser, reverse proxy, and CDN.
Separate storage, freshness, and audience
no-store tells caches not to store a response. no-cache permits storage but requires validation before reuse. private restricts storage to private caches; it does not mean “never save this.” s-maxage supplies a freshness lifetime for shared caches. These distinctions are defined in the Cache-Control reference.
For a hypothetical documentation site, I would begin with these illustrative policies:
| Response | Starting policy | Product assumption |
|---|---|---|
| Fingerprinted JavaScript | public, max-age=31536000, immutable | Changed bytes receive a new URL |
| Public article HTML | public, max-age=0, s-maxage=60 | A shared copy may be a minute old |
| Personal dashboard | private, no-cache | Browser storage is acceptable; reuse must validate |
| Sensitive downloadable report | no-store | The application should not invite cache storage |
These are starting assumptions, not universal defaults. A legal correction, embargo, or account change can invalidate the assumptions behind an otherwise sensible policy. Headers also do not revoke copies someone already downloaded.
Work backward from an actual update
Suppose an editor changes a public article at 10:00. The response above allows the CDN to serve a still-fresh copy until its lifetime expires. If the editorial requirement is “everyone sees the correction immediately,” a sixty-second allowance is the wrong contract unless a reliable purge closes that gap.
Write a timeline before changing the header:
- At 09:59:50, request the page and populate the shared cache.
- At 10:00:00, update the source.
- At 10:00:05, request from a separate browser session.
- Record whether the old body is allowed and when the new one must appear.
This turns “the CDN seems stale” into a falsifiable requirement. Repeat the exercise when the origin fails. Decide whether availability or freshness wins for this particular content. Do not quietly accept yesterday's permission state because serving stale articles worked well.
A cache key is part of correctness
If one URL has language variants, matching only the URL is insufficient. The Vary header identifies request headers involved in selecting a representation; for example, Vary: Accept-Language distinguishes language-dependent responses. See the Vary documentation.
For a product site, I would generally prefer explicit language URLs such as /de/pricing when they fit the information architecture. That makes the variant visible in logs, links, and test fixtures. This is a design preference, not a requirement of HTTP.
The dangerous variant is an authenticated response accidentally treated as public. Test with two accounts that have deliberately different data. A cache hit is only a success if the body belongs to the correct audience. A high hit ratio does not compensate for serving one customer's invoice to another.
Validation needs a stable representation
HTTP validators let a cache check whether a stored representation remains usable. A matching conditional request can receive a 304 Not Modified response instead of a new body. The normative rules for storage, validation, and freshness live in RFC 9111.
In a proposed implementation, derive the validator from content that actually determines the response. An article revision alone may be inadequate if the rendered page also includes a changing banner. Either include that dependency, separate the banner, or choose a different cache contract.
An illustrative manual check is:
curl -sS -D headers.txt -o page.html https://example.com/articles/cache
curl -sS -D - -o /dev/null \
-H 'If-None-Match: "replace-with-returned-etag"' \
https://example.com/articles/cache
This command sketch is not a test result. Run it against your deployment and inspect the body as well as the status. Provider-specific cache headers may help diagnose behavior, but their meaning must come from that provider's documentation.
Review the failure path before the hit ratio
I would reject a cache rollout without answers to three questions: who can purge it, how the team verifies the purge, and what happens if the purge fails. For fingerprinted assets, preserve old files long enough for clients with old HTML to load them. For user-specific pages, include logout and account switching in the test sequence.
The broader website checklist discussion is useful context for making this an explicit site requirement. The constrained web server example offers a complementary reminder to account for resource limits.
The policy I would ship is the smallest one whose update timeline, audience boundaries, and failure behavior the team can explain. Optimize its hit ratio after those properties are demonstrated.