API Design · Lesson 14

HTTP Cache Headers Explained

Understand Cache-Control, Age, Expires, Vary, freshness, validation, and browser versus shared cache behavior with Java and Node.js examples.

Quick answer

HTTP caching is controlled by response metadata and a cache’s stored state. Cache-Control defines reuse policy, Date anchors freshness calculations, Age reports how long a stored response has effectively been in the cache chain, Expires supplies an older absolute freshness deadline, and Vary identifies request fields used to select the right stored representation. Validators such as ETag and Last-Modified let a stale response be checked without transferring its full body.

A safe API policy is explicit. Decide who may reuse a response, how long it is fresh, which request dimensions change it, and how it will be validated after freshness ends. A URL containing a query component is not automatically uncacheable; it normally forms part of the cache key and still needs an explicit policy.

RFC 9111 obsoletes RFC 7234.

The HTTP cache decision model

For a GET or HEAD, a cache makes several decisions:

  1. Is this response storable under the method, status, request directives, response directives, and any authorization rules?
  2. Does a stored entry match the target URI and the request fields named by Vary?
  3. Is that selected entry fresh for this cache?
  4. If it is stale, may it be served under an explicit stale allowance, or must it be validated?
  5. If validation returns 304 Not Modified, which stored metadata must be updated before reuse?

Storage and reuse are different permissions. A cache may store a no-cache response but cannot reuse it without successful validation. Conversely, a response can remain stored after it becomes stale; staleness does not mean deletion.

The default cache key is primarily the request method and target URI, including the query component. GET /products?page=1 and GET /products?page=2 are normally different keys. Query parameters can create an enormous or attacker-controlled key space, but they do not themselves prohibit caching. Normalize application-generated URLs, reject irrelevant parameters, and configure CDN cache keys deliberately.

Invalidation after unsafe requests

A successful unsafe request changes the cache model even when its response is not itself cached. When a cache receives a non-error (2xx or 3xx) response to PUT, POST, DELETE, or a method whose safety is unknown, RFC 9111 requires it to invalidate the effective request URI—the target URI in RFC 9111 wording. Invalidate means remove matching stored responses or mark them invalid so they must validate before a later reuse.

The same-origin rule limits related Location and Content-Location invalidation: a cache may invalidate those candidate URIs after the successful unsafe request only when they share the effective request URI’s origin. This prevents a cross-origin header from evicting another site’s cache entries. Application-level CDN purge can cover broader relationships, but it is provider configuration, not a replacement for this protocol rule.

Private caches and shared caches

A private cache is dedicated to one user. A browser’s HTTP cache is the usual example. It may safely retain a user-specific representation when the response permits private storage.

A shared cache reuses responses for multiple users. CDNs, reverse proxies, and organization gateways are examples. Shared reuse saves origin work but creates the highest data-isolation risk. Cache-Control: private prevents shared caches from storing the response, while still allowing a private cache subject to the other directives.

Do not equate “browser” with private and “proxy” with shared in every architecture. A service worker can implement application-controlled caching, and a gateway can have partitioned stores. Define the trust boundary. Responses selected by cookies, authorization, tenant, language, or negotiated media type need a policy and key that preserve that boundary.

Which headers control caching?

HeaderRole
Cache-ControlDirectives for storage, freshness, validation, and stale reuse.
DateOrigin timestamp used in age and freshness calculations.
AgeA cache’s estimate, in whole seconds, of time since generation or successful validation.
ExpiresAbsolute freshness time used when a more specific max-age does not override it.
VaryRequest header fields that participate in representation selection.
ETagOpaque validator used with If-None-Match or If-Match.
Last-ModifiedTimestamp validator used with If-Modified-Since.

Cache-Control takes precedence over Expires when both define freshness. A malformed or past Expires value means the response is not fresh; it does not necessarily forbid storage. Pragma: no-cache is a legacy request mechanism, not a substitute for a deliberate modern response policy.

Date is normally generated by the origin server. Intermediaries calculate current age using Date, time spent in transit, resident time, and any received Age. An origin should not manufacture an Age value to describe database record age.

Freshness, staleness, and the Age header

Freshness lifetime says how long reuse is allowed without contacting the origin. Current age estimates how old the stored response is. In simplified terms, a response is fresh while:

current_age < freshness_lifetime

The real RFC 9111 calculation corrects for an origin clock that appears behind the cache, adds response delay, respects an upstream Age, and adds resident time. This matters with multiple cache hops: a downstream cache must not reset apparent age simply because it received the response recently.

Age: 37 therefore estimates 37 seconds since the response was generated or last successfully validated through the cache chain. It is not a guarantee that every byte of application data is only 37 seconds old. A JSON document generated from a nightly snapshot may already contain older business data.

Once current age reaches the applicable lifetime, the response is stale. A stale entry can be validated with an ETag or Last-Modified, served under a directive such as stale-if-error, or rejected when policy requires validation. must-revalidate prevents a cache from serving stale content just because the origin is disconnected, except where the protocol explicitly permits an exception.

How Vary changes representation selection

Vary extends selection beyond the URI. This response:

Vary: Accept-Encoding, Accept-Language

means a cache can reuse the stored representation only when the new request’s nominated header values match according to HTTP rules. It prevents a gzip response from being sent to a client that did not negotiate gzip and keeps an English representation separate from a French one.

Only vary on fields that truly change the representation. Vary: User-Agent often fragments a cache into thousands of low-value variants. Vary: Authorization is not a substitute for private or a reviewed shared-cache design. Vary: * means the response cannot be matched to a later request, so it defeats ordinary reuse.

When an origin selects Access-Control-Allow-Origin dynamically, Vary: Origin can prevent one allowed origin’s response metadata from being reused for another. Also review whether the cache vendor includes or ignores a field in its configured cache key; protocol-correct metadata and platform configuration must agree.

Java Spring Boot cache header example

This controller exposes an inspectable version, emits the required metadata, and supports ETag validation. Injecting Clock keeps the Date deterministic in tests.

@RestController
@RequestMapping("/api/catalog")
final class CatalogController {
    private static final String ETAG = "\"catalog-v42\"";
    private final Clock clock;

    CatalogController(Clock clock) {
        this.clock = clock;
    }

    @GetMapping
    ResponseEntity<?> catalog(WebRequest request) {
        HttpHeaders headers = new HttpHeaders();
        headers.setDate(clock.millis());
        headers.set(
            HttpHeaders.CACHE_CONTROL,
            "public, max-age=5, s-maxage=10, must-revalidate"
        );
        headers.setVary(List.of("Accept-Encoding", "Accept-Language"));
        headers.setETag(ETAG);
        headers.set("X-Origin-Version", "catalog-v42");

        if (request.checkNotModified(ETAG)) {
            return new ResponseEntity<>(headers, HttpStatus.NOT_MODIFIED);
        }

        Map<String, Object> body = Map.of(
            "version", 42,
            "items", List.of("keyboard", "monitor")
        );
        return new ResponseEntity<>(body, headers, HttpStatus.OK);
    }
}

WebRequest.checkNotModified applies HTTP entity-tag parsing rather than comparing one raw string. For If-None-Match on GET or HEAD, RFC 9110 requires weak comparison: W/"catalog-v42" matches "catalog-v42". The field can also contain a list of tags, and * matches any current representation. Do not reuse weak comparison for If-Match, which requires strong comparison.

In production, derive a validator from representation state, not wall-clock time on every request. If language changes the body, each language needs a corresponding validator or a validator that incorporates that variant.

Node.js Express cache header example

Express can implement the same wire contract. Node.js normally adds Date automatically; setting it here makes the example explicit and testable.

import express from "express";

const app = express();
const etag = '"catalog-v42"';

app.get("/api/catalog", (req, res) => {
  res.set({
    Date: new Date().toUTCString(),
    "Cache-Control": "public, max-age=5, s-maxage=10, must-revalidate",
    Vary: "Accept-Encoding, Accept-Language",
    ETag: etag,
    "X-Origin-Version": "catalog-v42"
  });

  if (req.fresh) {
    return res.status(304).end();
  }

  return res.json({
    version: 42,
    items: ["keyboard", "monitor"]
  });
});

Express calculates req.fresh after the handler sets the selected ETag. Express 4 delegates to fresh 0.5.x, whose comma tokenization is not a complete RFC entity-tag parser. Keep tags comma-free (version, hex, or base64url values) and test that invariant; otherwise use a vetted quote-aware parser, not split(",").

Do not set Age here. A cache adds or updates it. Preserve any upstream Vary fields when middleware adds another; replacing the header can silently collapse representations.

Verify the flow with curl

curl has no general HTTP response cache, so repeated origin calls alone cannot prove reuse. Send these requests through the configured CDN or reverse-cache URL and inspect its documented cache-status header. Here X-Cache is illustrative:

curl -i "https://cache.example.test/api/catalog?probe=42"
curl -i "https://cache.example.test/api/catalog?probe=42"
sleep 11
curl -i "https://cache.example.test/api/catalog?probe=42"

Expect a body-bearing miss, then a fresh hit with a larger Age and no origin request. After ten seconds, must-revalidate requires validation: the cache can send If-None-Match, receive 304, refresh metadata, and return its stored body. Provider labels differ, so correlate origin logs and Age.

Use a unique probe value for a clean experiment, then keep it identical. The query component makes a distinct cache key; it does not disable caching. A direct validator check is also useful:

curl -i -H 'If-None-Match: "catalog-v42"' \
  "https://origin.example.test/api/catalog?probe=42"

That request should return 304, metadata, and no representation body.

Diagnose a browser cache in Developer Tools

Open browser Developer Tools in the Network panel, preserve the log, then load the same URL twice without changing its query or request headers. Inspect the transfer or size column and the cache source: browsers commonly label memory or disk reuse, while a network response has transferred bytes. Open each request’s headers and record response Age, Cache-Control, ETag, Last-Modified, and Vary; then inspect request If-None-Match, If-Modified-Since, Cache-Control: no-cache, and any Accept-Language or encoding variant inputs. A 304 explains a validation path, whereas a browser-cache entry can have no network transfer at all.

Use Disable cache only as a controlled comparison. In Chromium-based tools it applies while Developer Tools is open and prevents the browser HTTP cache for that session; it does not clear or bypass a CDN, reverse proxy, service worker, or an origin-side cache. Compare the Network evidence with an origin request ID and CDN status header before concluding which layer served the response.

Heuristic freshness and explicit policies

When a response has no explicit freshness lifetime, a cache may calculate heuristic freshness if the response is otherwise eligible. A common algorithm uses a fraction, often 10%, of the time since Last-Modified. That is an implementation choice bounded by RFC requirements, not an API contract.

Heuristics create surprises across browsers and CDNs, especially for status codes cacheable by default. Prefer explicit Cache-Control on APIs, including deliberate negative responses. A query-bearing URI is still eligible for caching under modern HTTP rules; explicit freshness avoids depending on older implementations and folklore about query strings.

Common mistakes

  • Treating no-cache as “do not store”; it actually requires validation before reuse.
  • Assuming Set-Cookie automatically makes a response uncacheable instead of setting an explicit private or sensitive policy.
  • Adding Vary fields that do not change the representation and destroying hit rate.
  • Omitting Vary for language, encoding, or dynamic origin selection and serving the wrong variant.
  • Testing repeated requests with plain curl against the origin and calling the second one a cache hit.
  • Using a timestamp-based ETag that changes on every request, making validation useless.
  • Purging one CDN while a browser or another cache layer still holds a fresh entry.

Edge cases

Authenticated requests need special review: shared caches normally cannot store a response to a request containing Authorization unless an applicable directive permits it. Partial 206 responses, redirects, errors, and HEAD metadata have status- and method-specific rules. Clock skew can inflate apparent age. A 304 response can update stored headers, so ensure it carries correct current metadata and does not accidentally remove a multi-valued Vary.

Tests and debugging

Test wire behavior: Spring MockMvc should cover Date, exact policy, Vary, ETag, body version, and legal matching forms:

@ParameterizedTest
@ValueSource(strings = {
    "W/\"catalog-v42\"",
    "\"other\", W/\"catalog-v42\"",
    "*"
})
void returns304ForMatchingIfNoneMatch(String value) throws Exception {
    mockMvc.perform(get("/api/catalog")
            .header(HttpHeaders.IF_NONE_MATCH, value))
        .andExpect(status().isNotModified())
        .andExpect(content().string(""));
}

The equivalent Supertest regression protects the Express path:

assert.doesNotMatch(
  etag,
  /,/,
  "req.fresh example requires a comma-free current ETag"
);

for (const value of [
  'W/"catalog-v42"',
  '"other", W/"catalog-v42"',
  "*"
]) {
  const response = await request(app)
    .get("/api/catalog")
    .set("If-None-Match", value);

  assert.equal(response.status, 304);
  assert.equal(response.text, "");
}

Also assert a non-matching list returns 200; freeze time at a second boundary. At integration level, compare an origin request ID across cache-facing calls: a fresh hit must not increment it, while a stale entry should make a conditional request. Test language and encoding variants; do not expose diagnostic keys containing credentials.

Practical checklist

  • Classify the response and set explicit Cache-Control.
  • Let caches calculate Age; choose stable validators and minimal Vary.
  • Normalize query keys and reject irrelevant parameters.
  • Test miss, hit, stale validation, changed content, and variants.
  • Verify browser, CDN, and reverse-proxy behavior independently.
  • Monitor origin load, revalidation, hit rate, and variant counts.

Frequently asked questions

Does Age: 0 prove a cache hit?

No. It can be a new, validated, or briefly resident entry. Use cache diagnostics and origin observations too.

Are URLs with query parameters cacheable?

Yes. It normally participates in the cache key; explicit policy and bounded cardinality still matter.

Does 304 Not Modified have a response body?

No body is sent; a cache combines allowed 304 metadata with its stored response.

Should an API use Expires or max-age?

Prefer relative Cache-Control: max-age; it composes with shared-cache directives and overrides Expires.

It fragments caches and retains sensitive key material. Prefer private or a reviewed surrogate design.

Sources

Knowledge check

Check your understanding

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

1. After a successful PUT updates /products/42, which cached response must a shared cache invalidate?

2. In browser Developer Tools, what does a 304 response tell you about a stale cached API response?