API Design · Lesson 15

Cache-Control Explained for APIs

Choose safe API caching policies with max-age, s-maxage, private, no-cache, no-store, revalidation, and practical Java and Node.js recipes.

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:

  1. May any cache store this response?
  2. May a shared cache store and reuse it across users?
  3. For how long may each kind of cache reuse it without validation?
  4. 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 classExact policyIntended audiencePrimary tradeoff
Public API summarypublic, max-age=60, s-maxage=300Browsers and shared cachesUp to five minutes of shared staleness
Private user viewprivate, max-age=30One user’s private cacheBrief client-side staleness
Public, always validatedno-cacheEligible private/shared cachesOrigin round trip on every reuse
Sensitive materialno-storeNo HTTP cacheNo cache performance or offline reuse
Versioned public asset/datapublic, max-age=31536000, immutableBrowsers and shared cachesURL 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-cache when the requirement is “never store.”
  • Treating no-store as a way to delete responses already cached.
  • Marking authenticated output public because the endpoint is a GET.
  • Applying year-long immutable caching 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 404 can 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-age and s-maxage.
  • Pair no-cache with a stable ETag or Last-Modified.
  • Use immutable only 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

Knowledge check

Check your understanding

Answer both questions correctly to mark this lesson as mastered. You can retry without penalty.

1. Which Cache-Control policy best fits a personalized dashboard that a browser may retain only after validation?

2. What does must-understand require before a cache stores a response under a status-specific caching policy?