How do Cache-Control freshness and ETag/Last-Modified validation work together, and how would you combine them in an API?
answer
- freshness (max-age) = reuse, no request
- validation (ETag/Last-Modified) = 304 after stale
- combine: short max-age + ETag
- ETag wins over If-Modified-Since
- s-maxage/stale-while-revalidate/private for CDNs
basics
~20 smax-age gives freshness: caches reuse the response with no request until it expires. ETag/Last-Modified give validation: after expiry the client asks 'still valid?' and gets a cheap 304 if unchanged. Combine a short max-age with an ETag so caches serve fast, then revalidate cheaply.
solid answer
~40 sThere are two independent HTTP caching mechanisms. Freshness (Cache-Control max-age / s-maxage) lets a cache reuse a stored response with zero network round trips until it goes stale. Validation (ETag with If-None-Match, or Last-Modified with If-Modified-Since) lets the client, once a response is stale, ask the origin whether its copy is still good and receive a body-less 304 Not Modified if so. They compose: set a modest max-age for fast, request-free reuse, plus an ETag/Last-Modified so that after expiry revalidation is cheap instead of a full re-download. In Spring you attach CacheControl.maxAge(...) via ResponseEntity and supply the validator with .eTag()/.lastModified() or via WebRequest.checkNotModified. Add staleWhileRevalidate to serve slightly-stale content while revalidating in the background, and stale-if-error for resilience. Use private/s-maxage correctly when a CDN sits in front.
code
java · 28 linesimport org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.WebRequest;
import java.time.Duration;
@RestController
@RequestMapping("/api/catalog")
class CatalogController {
private final CatalogService service;
CatalogController(CatalogService service) { this.service = service; }
@GetMapping("/{id}")
ResponseEntity<ItemDto> get(@PathVariable long id, WebRequest req) {
String etag = service.versionTag(id); // cheap version marker
if (req.checkNotModified(etag)) { // validation half
return null; // 304, no body
}
ItemDto dto = service.load(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofSeconds(60)) // freshness half
.cachePublic()
.staleWhileRevalidate(Duration.ofSeconds(30)))
.eTag(etag)
.body(dto);
}
}go deeper
Know max-age means reuse without asking and ETag lets the server say 'not changed'.
Explain fresh vs stale and that validation yields a 304 after expiry; set both in a ResponseEntity.
Combine a short max-age with an ETag, understand ETag-over-Last-Modified precedence, and use private/s-maxage correctly with a CDN.
Architect edge vs browser TTLs (s-maxage), resilience (stale-if-error), background refresh (stale-while-revalidate), immutable-asset URLs, and the privacy correctness of shared caching.
## Two orthogonal mechanisms HTTP caching has **two** distinct levers that people often conflate: **1. Freshness** — governed by `Cache-Control: max-age=<seconds>` (and `s-maxage` for shared caches) or the legacy `Expires` header. While a response is *fresh*, a cache serves it **directly, with no request to the origin at all**. Zero latency, zero server load. When `max-age` elapses the response becomes **stale**. **2. Validation** — governed by **validators**: an `ETag` (opaque version id) or `Last-Modified` (timestamp). When a cached response is stale, the cache doesn't blindly re-download; it sends a **conditional request** echoing the validator (`If-None-Match: "etag"` or `If-Modified-Since: <date>`). If unchanged, the origin returns **`304 Not Modified`** with no body (cheap), and the cache refreshes the stored copy's freshness. If changed, it returns `200` with the new body. ## Why combine them - **max-age alone**: fast reuse, but once stale you pay a **full re-download** even if nothing changed. - **ETag alone** (or `no-cache`): every use requires a round trip; correct but chatty. - **Both**: fresh window serves instantly with no traffic; after expiry you pay only a tiny `304` if unchanged. This is the sweet spot for most read APIs. ## Doing it in Spring ```java return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(Duration.ofSeconds(60)).cachePublic()) .eTag(currentVersionTag) // sets ETag .lastModified(updatedAtMillis) // sets Last-Modified .body(dto); ``` Or push the validator earlier with `WebRequest.checkNotModified(etag, lastModifiedMillis)` to skip rendering on a match. `ShallowEtagHeaderFilter` can supply the ETag automatically for the validation half. ## Precedence rules - If both `If-None-Match` and `If-Modified-Since` are present, **ETag wins** — the timestamp is only consulted when there's no ETag. - A **strong** ETag permits range requests and byte-exact caching; a **weak** (`W/`) ETag asserts only semantic equivalence. ## Advanced Cache-Control directives - `s-maxage` — freshness for **shared** caches (CDN), overriding `max-age` there; lets you cache longer at the edge than in the browser. - `stale-while-revalidate=<s>` — a cache may serve a **slightly stale** copy immediately while it revalidates in the background, hiding origin latency. - `stale-if-error=<s>` — serve stale content if the origin errors, improving resilience. - `must-revalidate` — once stale, the cache **must not** serve it without successful revalidation (no stale fallback). - `private` vs `public` — keep user-specific responses out of shared caches. ## Gotchas - Putting `no-cache` or `no-store` alongside `max-age` is contradictory/redundant and disables reuse. - A CDN with `public, s-maxage` can serve one user's personalized response to another if you forget `private` — a real security bug. - Validators must be **cheap to compute** to be worthwhile at the handler level; otherwise validation costs as much as rendering. - `Last-Modified` has 1-second resolution — prefer ETags for rapidly-changing resources. ## When to use - Static-ish assets: long `max-age` + content-hash in URL (immutable) → rarely revalidate. - Dynamic read APIs: short `max-age` + `ETag` → fast plus cheap revalidation. - Sensitive/user data: `no-store` or `private, no-cache`.
- If both an ETag and a Last-Modified are present on a conditional request, which one decides the 304?The ETag (If-None-Match) takes precedence; Last-Modified/If-Modified-Since is only used when there is no ETag to compare.
- What does stale-while-revalidate buy you?The cache may serve a slightly stale copy instantly while asynchronously revalidating with the origin, hiding revalidation latency from the user without ever blocking on a round trip.
saying these in an interview costs you the question
- Treating max-age and ETag as the same mechanism
- Combining no-store with max-age and expecting caching
- Using public/s-maxage on personalized responses behind a CDN
- Assuming Last-Modified is consulted even when an ETag is present