Quick answer
Cache-Control is a set of directives, not a universal on/off header. Choose a policy from the response’s audience, sensitivity, acceptable staleness, validator, and URL stability. Public catalog data can be shared briefly, user-specific data belongs in private caches, secrets should not be stored, and versioned static representations can be cached for a year because their URL changes with their content.
Use reviewed policy profiles instead of allowing handlers to concatenate arbitrary directives. Test the exact header on the wire and verify actual browser and shared-cache behavior.
RFC 9111 obsoletes RFC 7234.
Cache-Control is a policy, not a single switch
A response policy answers four questions:
- May any cache store this response?
- May a shared cache store and reuse it across users?
- For how long may each kind of cache reuse it without validation?
- What must happen when it becomes stale?
These concerns are independent. private, max-age=30 permits a browser cache to reuse a personalized response for thirty seconds but excludes shared storage. no-cache permits storage but demands successful validation before every reuse. A response no-store directive asks caches not to store that response; the request form prohibits storing the request and any response to it.
Caching cannot repair a broken authorization boundary. Before selecting a header, identify every input that changes the representation: tenant, user, permissions, locale, fields, query, encoding, and deployment version. Then ensure the URI and Vary behavior select the correct representation.
Request directives and response directives
Clients can send request directives such as no-cache, max-age=0, max-stale, min-fresh, only-if-cached, and no-store. They constrain how a cache satisfies that request; they do not rewrite the stored response’s policy.
Request max-age=0 only asks for a response whose current age is no greater than zero; a just-generated or successfully validated age-zero response can still satisfy it without another validation. Request no-cache expresses the client’s preference that a stored response not be used without successful validation at the origin. only-if-cached asks a cache to respond from stored state and return 504 Gateway Timeout when it cannot. Request-directive support varies, especially in browsers, so do not build correctness around a UI refresh button.
Response directives are origin policy. They include max-age, s-maxage, public, private, no-cache, no-store, must-revalidate, proxy-revalidate, and extension directives. Unknown extensions are generally ignored, which allows protocol evolution but means a critical privacy rule must not depend only on a niche extension.
max-age and s-maxage
max-age=60 gives a sixty-second freshness lifetime measured from response generation, corrected through the cache age calculation. It is not sixty seconds from when each browser receives the response. An upstream Age: 45 leaves roughly fifteen seconds of freshness for a downstream cache.
s-maxage=300 supplies a different lifetime for shared caches and overrides max-age or Expires there. Private caches still use max-age=60. The s-maxage directive also carries shared-cache revalidation semantics equivalent to proxy-revalidate once stale.
This policy suits public API data where the CDN may be five minutes behind while browsers should check more frequently:
Cache-Control: public, max-age=60, s-maxage=300
The tradeoff is controlled staleness. Invalidation can shorten it operationally, but correctness should tolerate the declared five-minute shared lifetime. If it cannot, shorten the lifetime or require validation.
public and private
public explicitly permits shared caching, including in cases where a response would otherwise be ineligible under default authorization rules. It does not mean the data is safe to publish. Only use it after proving that any user may receive the same representation under the selected cache key.
private restricts storage to private caches. This policy fits a low-risk personalized dashboard that may remain on the user’s device briefly:
Cache-Control: private, max-age=30
The audience is one user agent, not a CDN. The tradeoff is that back-button and repeat navigation can be fast while changes may remain unseen for thirty seconds. It is not suitable for responses containing one-time codes, raw tokens, or highly sensitive financial data.
Field-name forms such as private="Set-Cookie" exist in HTTP, but support is inconsistent and mistakes are costly. Prefer classifying the entire response as private.
no-cache versus no-store
For a representation that may be stored but must be checked before reuse:
Cache-Control: no-cache
The audience can include private and, when otherwise permitted, shared caches. The tradeoff is bandwidth savings through 304 Not Modified, but every reuse needs an origin validation round trip. Without an ETag or Last-Modified, no-cache usually degenerates into fetching the full response.
For a sensitive response that must not be intentionally retained by HTTP caches:
Cache-Control: no-store
This fits token responses, password-reset material, or an export containing secrets. The tradeoff is no cache-assisted reuse and no offline history behavior. no-store is not a retroactive purge, cannot erase application logs or screenshots, and is not a complete privacy mechanism. Transport security, data minimization, browser controls, and log redaction still matter.
Avoid the reflexive no-cache, no-store combination. It is often copied without a threat model and obscures whether validation or prohibition is intended.
Revalidation and stale-response directives
must-revalidate says a stale response cannot be reused without successful validation, including when the origin is disconnected. proxy-revalidate applies that rule only to shared caches. These directives matter after freshness ends; they do not force validation while an entry is fresh.
stale-while-revalidate=30 permits a cache to serve stale content for a bounded interval while validation happens asynchronously. It lowers latency but allows a client to observe data older than the normal freshness lifetime. stale-if-error=600 permits stale reuse when an origin request fails, trading freshness for resilience. RFC 5861 defines both extensions; check platform support and decide which errors qualify.
Do not add stale allowances to permission decisions, balances, inventory reservations, or revocation state without a domain-specific risk analysis. “Available” is not the same as “correct.”
must-understand and status-code caching requirements
must-understand limits storage to a cache that understands and conforms to the caching requirements for the response’s status code. It is useful when a response’s status has caching behavior that an older or simplified cache might not implement correctly; recognizing the number is not enough—the cache must implement that status code’s caching requirements.
Pair it with no-store in the usual fallback form:
Cache-Control: must-understand, no-store
A cache that does not understand must-understand obeys no-store and will not retain the response. A cache that does understand the directive and the status code’s requirements may ignore the no-store fallback and cache according to those requirements. That deliberate exception means this combination is not a secrecy policy; use an unqualified no-store for sensitive material.
Treat support as a deployment assumption to test, not a header you can infer from documentation. Exercise the actual response status through supported browsers, reverse proxies, and the configured CDN; inspect storage or cache-status diagnostics, then repeat after upgrades and Cache Rule changes. If any required hop cannot demonstrate the intended status-code behavior, retain a simpler conservative policy.
API caching policy decision table
| Response class | Exact policy | Intended audience | Primary tradeoff |
|---|---|---|---|
| Public API summary | public, max-age=60, s-maxage=300 | Browsers and shared caches | Up to five minutes of shared staleness |
| Private user view | private, max-age=30 | One user’s private cache | Brief client-side staleness |
| Public, always validated | no-cache | Eligible private/shared caches | Origin round trip on every reuse |
| Sensitive material | no-store | No HTTP cache | No cache performance or offline reuse |
| Versioned public asset/data | public, max-age=31536000, immutable | Browsers and shared caches | URL must change whenever content changes |
The long-lived immutable policy is:
Cache-Control: public, max-age=31536000, immutable
Its audience is everyone, and its benefit is avoiding validation for a year. Use it only for content-addressed or versioned URLs such as /schemas/orders.7f3a.json. RFC 8246’s immutable extension says the representation will not change while fresh; it does not make an unversioned /api/config safe to freeze.
Java Spring Boot policy helper
An enum exposes reviewed semantic profiles. Controllers cannot smuggle an arbitrary string from configuration or a request into Cache-Control.
enum CacheProfile {
PUBLIC_API,
PRIVATE_USER,
PUBLIC_REVALIDATE,
SENSITIVE,
VERSIONED_PUBLIC
}
final class ApiCachePolicy {
private ApiCachePolicy() {}
static String value(CacheProfile profile) {
return switch (profile) {
case PUBLIC_API ->
"public, max-age=60, s-maxage=300";
case PRIVATE_USER ->
"private, max-age=30";
case PUBLIC_REVALIDATE ->
"no-cache";
case SENSITIVE ->
"no-store";
case VERSIONED_PUBLIC ->
"public, max-age=31536000, immutable";
};
}
static HttpHeaders headers(CacheProfile profile) {
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.CACHE_CONTROL, value(profile));
return headers;
}
}
Use the classification at the endpoint:
@GetMapping("/api/catalog/summary")
ResponseEntity<CatalogSummary> summary() {
HttpHeaders headers =
ApiCachePolicy.headers(CacheProfile.PUBLIC_API);
headers.setVary(List.of("Accept-Encoding"));
return new ResponseEntity<>(
catalogService.summary(), headers, HttpStatus.OK
);
}
SENSITIVE and PRIVATE_USER are distinct on purpose. A code review can challenge the chosen profile without deciphering a string. Keep authentication and authorization checks independent of this helper.
Node.js Express policy middleware
Use the same closed set in Node.js:
export const CacheProfile = Object.freeze({
PUBLIC_API: "PUBLIC_API",
PRIVATE_USER: "PRIVATE_USER",
PUBLIC_REVALIDATE: "PUBLIC_REVALIDATE",
SENSITIVE: "SENSITIVE",
VERSIONED_PUBLIC: "VERSIONED_PUBLIC"
});
const values = Object.freeze({
[CacheProfile.PUBLIC_API]:
"public, max-age=60, s-maxage=300",
[CacheProfile.PRIVATE_USER]:
"private, max-age=30",
[CacheProfile.PUBLIC_REVALIDATE]:
"no-cache",
[CacheProfile.SENSITIVE]:
"no-store",
[CacheProfile.VERSIONED_PUBLIC]:
"public, max-age=31536000, immutable"
});
export function cachePolicy(profile) {
if (!Object.hasOwn(values, profile)) {
throw new TypeError("Unknown cache profile");
}
const value = values[profile];
return function setCachePolicy(_req, res, next) {
res.set("Cache-Control", value);
next();
};
}
app.get(
"/api/catalog/summary",
cachePolicy(CacheProfile.PUBLIC_API),
(_req, res) => {
res.vary("Accept-Encoding");
res.json({ version: 42, itemCount: 128 });
}
);
Fail closed for unknown profiles. A frozen ordinary object still inherits keys such as toString and constructor, so checking the retrieved value’s truthiness is insufficient. Object.hasOwn admits only explicitly declared profiles. Do not accept req.query.cachePolicy, a database string, or an unrestricted environment value. If a new business case appears, add a named profile with tests and security review.
Verify policies with curl
Inspect exact origin metadata first:
curl -i "https://api.example.test/api/catalog/summary"
curl -i "https://api.example.test/api/me/dashboard"
curl -i "https://api.example.test/oauth/token"
curl -i "https://api.example.test/schemas/orders.7f3a.json"
Confirm each route has its intended literal header. Then test the public route through the shared-cache hostname twice with the identical query and negotiated headers:
curl -i "https://cache.example.test/api/catalog/summary?region=us"
curl -i "https://cache.example.test/api/catalog/summary?region=us"
curl -i -H "Cache-Control: no-cache" \
"https://cache.example.test/api/catalog/summary?region=us"
The second response should show fresh reuse through the provider’s cache diagnostics and Age. Request no-cache explicitly asks the cache to validate a stored response at the origin before using it; it does not purge the object. A request max-age=0 would not guarantee validation because an age-zero stored response remains acceptable. Verify the actual conditional origin call because request-directive support is cache-specific. Confirm that a private or no-store endpoint never becomes a shared hit. Since curl itself does not provide a general HTTP cache, the cache-facing hostname is essential.
Tests for policy regressions
A Java parameterized test can pin every enum mapping:
@ParameterizedTest
@CsvSource({
"PUBLIC_API, 'public, max-age=60, s-maxage=300'",
"PRIVATE_USER, 'private, max-age=30'",
"PUBLIC_REVALIDATE, no-cache",
"SENSITIVE, no-store",
"VERSIONED_PUBLIC, 'public, max-age=31536000, immutable'"
})
void emitsReviewedPolicy(CacheProfile profile, String expected) {
assertEquals(expected, ApiCachePolicy.value(profile));
}
Add MockMvc tests against representative endpoints so a controller cannot omit or replace the helper. For Express, use Supertest:
it("marks token responses sensitive", async () => {
const response = await request(app)
.post("/oauth/token")
.send({ grant_type: "client_credentials" });
assert.equal(response.headers["cache-control"], "no-store");
});
it("rejects inherited and unknown profile names", () => {
for (const profile of [
"toString", "constructor", "__proto__", "UNKNOWN"
]) {
assert.throws(
() => cachePolicy(profile),
/Unknown cache profile/
);
}
});
The negative test proves inherited names as well as an ordinary unknown value throw during route setup. At an environment with a real shared cache, assert public reuse and assert personalized endpoints never hit across two isolated clients.
Common mistakes
- Using
no-cachewhen the requirement is “never store.” - Treating
no-storeas a way to delete responses already cached. - Marking authenticated output
publicbecause the endpoint is aGET. - Applying year-long
immutablecaching to a stable URL whose content changes. - Assuming a CDN obeys origin headers when a page rule overrides them.
- Forgetting error responses; a cached temporary
404can outlive a deployment. - Allowing handler code to assemble contradictory or unreviewed directive strings.
- Using purge as the only consistency mechanism instead of bounding freshness.
Edge cases
A response to a request with Authorization has special shared-storage restrictions unless directives explicitly permit it; never add public mechanically. Set-Cookie does not universally prohibit caching, so set an explicit policy. Redirects, 404, 410, 206, and HEAD can be cacheable under specific rules. A service worker or application store is not necessarily governed like an HTTP cache. no-store on a response also cannot control backups, observability pipelines, or browser extensions.
Practical checklist
- Inventory response classes before choosing directives.
- Mark cross-user representations public only after key review.
- Keep personalized responses private; use no-store for sensitive material.
- Define separate browser and shared freshness with
max-ageands-maxage. - Pair
no-cachewith a stableETagorLast-Modified. - Use
immutableonly with content-versioned URLs. - Decide whether stale-on-error behavior is acceptable for each domain.
- Centralize reviewed profiles in Java and Node.js.
- Test literal headers on success, error, and conditional responses.
- Verify behavior through every CDN and reverse-proxy layer.
- Monitor hit rate, age, origin validation, and cross-tenant safety signals.
Frequently asked questions
Does public expose a response to search engines?
Not by itself. It permits shared-cache storage; access control, discoverability, robots directives, and URLs are separate. Still, never label confidential data public.
Does private encrypt a cached response?
No. It restricts shared caching. TLS, device security, and application controls handle confidentiality.
Is no-cache slower than max-age=0?
As request directives, no-cache asks that a stored response not be reused without successful origin validation. max-age=0 only asks for a response no older than zero seconds, so an age-zero stored response can satisfy it without validation. Performance and enforcement depend on validators, network path, and cache implementation.
Can I use s-maxage without public?
Yes. s-maxage explicitly targets shared caches and can authorize shared storage in relevant cases. Including public can make audience intent clearer, but review the full response and authorization rules.
How should APIs invalidate cached content?
Prefer bounded freshness and versioned URLs where possible. Use targeted purge for urgent operational correction, then verify every cache layer. Purge does not replace a correct policy.
Sources
- RFC 9111 Section 5.2.1: request directives
- RFC 9111 Section 5.2.2.3: must-understand
- RFC 9110: HTTP Semantics
- RFC 5861: stale-while-revalidate and stale-if-error
- RFC 8246: HTTP immutable responses
- Spring Framework HTTP caching documentation
- Express response API
Related reading
- Build the model in HTTP Cache Headers Explained.
- Add correct validators with ETag and Conditional Requests Explained.
- Apply policies at the edge in CDN Caching Strategies for APIs.
- Protect credentials with Prevent Sensitive Data Caching.
- Continue through API Design or explore topic clusters.