Design the HTTP caching strategy for a Spring MVC API with a mix of large expensive reports, small dynamic JSON, and per-user data behind a CDN. Which mechanism for each and why?
answer
- match tool to cost + privacy profile
- expensive/large -> checkNotModified (saves compute)
- small/cheap -> ShallowEtag filter (bandwidth, zero code)
- per-user + CDN -> private / no-store (leak risk)
- validator must be cheaper than the body
basics
~20 sExpensive reports: WebRequest.checkNotModified with a cheap version marker so a 304 skips the work. Small dynamic JSON: ShallowEtagHeaderFilter (zero code, saves bandwidth) plus a short max-age. Per-user data behind a CDN: Cache-Control private/no-store so shared caches never store it.
solid answer
~50 sMatch the mechanism to the cost profile and privacy. For large, expensive-to-produce reports, cheap recomputation of a validator matters, so use handler-level WebRequest.checkNotModified(etag/lastModified) to short-circuit before the DB/render step — this saves real compute, which ShallowEtagHeaderFilter cannot (it buffers and hashes the full body, saving only bandwidth, and would blow memory on large payloads anyway). For small, cheap dynamic JSON, the shallow filter is attractive: zero controller changes, bandwidth savings, negligible hashing cost; add a short public max-age so most reads never hit the origin. For per-user data behind a shared CDN, correctness dominates: mark it Cache-Control: private (or no-store for sensitive data) so the CDN never caches one user's response and serves it to another; if you do edge-cache, key on the auth/user and use s-maxage carefully. Combine freshness (max-age) with validation (ETag) where both help.
code
java · 26 lines// 1) Expensive report: validate cheaply BEFORE producing it
@GetMapping("/reports/{id}")
ResponseEntity<ReportDto> report(@PathVariable long id, WebRequest req) {
String version = reports.cheapVersionTag(id); // no heavy query
if (req.checkNotModified(version)) return null; // 304, skip the work
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePrivate())
.eTag(version)
.body(reports.generate(id)); // heavy path only on miss
}
// 2) Small cheap JSON: let the filter do it, plus a short public window
@Bean
FilterRegistrationBean<ShallowEtagHeaderFilter> etag() {
var reg = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
reg.addUrlPatterns("/api/lookups/*"); // small endpoints only
return reg;
}
// 3) Per-user data behind a CDN: never shared-cache
@GetMapping("/me/dashboard")
ResponseEntity<DashboardDto> dashboard() {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore()) // or .noCache().cachePrivate()
.body(dashboards.forCurrentUser());
}go deeper
Recognize there are different tools (headers vs filter vs conditional check) rather than one setting.
Pick the filter for small responses and Cache-Control for freshness; know per-user data shouldn't be shared-cached.
Justify checkNotModified for expensive endpoints (compute saved) vs the shallow filter (bandwidth only), and set private/no-store correctly for CDNs.
Own an endpoint-by-endpoint strategy driven by cost/size/change-rate/privacy, reason about CDN cache keys and s-maxage, resilience directives, validator cost, and the cross-user leakage failure mode, with metrics to validate it.
## The framing There is no single 'HTTP caching' switch — you choose per endpoint based on **(a) cost to produce the body**, **(b) size**, **(c) how often it changes**, and **(d) whether it's shared-cacheable (privacy)**. The three Spring tools map onto different cost/privacy profiles: | Tool | Saves | Costs | Best for | |---|---|---|---| | `Cache-Control` (`CacheControl` builder) | round trips (freshness) | none | anything with a tolerable staleness window | | `ShallowEtagHeaderFilter` | bandwidth only | buffers+MD5 whole body | small/cheap dynamic responses | | `WebRequest.checkNotModified` | compute **and** bandwidth | you must have a cheap validator | expensive/large responses | ## 1. Large, expensive reports These hit the DB hard and render big payloads. The shallow filter is **wrong** here twice: it runs the whole report before hashing (no compute saved) and it **buffers the entire response in memory** (memory pressure, breaks streaming). Instead, store or cheaply derive a **version marker** — a `max(updatedAt)` over the underlying rows, a monotonically increasing report version, or a precomputed hash — and call `WebRequest.checkNotModified(etag, lastModifiedMillis)` **before** generating the report. On a match: return null → `304`, no query, no render. Add `Cache-Control: max-age=…` for a short freshness window so repeated views within it don't even revalidate. Consider streaming (`StreamingResponseBody`) for the miss path so you never buffer. ## 2. Small dynamic JSON Cheap to recompute, small bodies. Here `ShallowEtagHeaderFilter` shines: register it for `/api/*`, get automatic ETags and `304`s with **zero controller code**, and the MD5 over a small body is negligible. It won't save compute, but for cheap endpoints that's fine — the win is cutting redundant downloads for polling clients. Layer a short `Cache-Control: max-age=<few seconds>, public` so most polls are answered from cache without any request. If some of these are cheap to validate but not to render, prefer `checkNotModified` selectively. ## 3. Per-user data behind a CDN Privacy is the priority. A shared cache (CDN/proxy) that stores a `public` personalized response can serve **user A's data to user B** — a real security incident. So: - Sensitive data → `Cache-Control: no-store` (nothing retained anywhere). - User-specific but non-sensitive → `Cache-Control: private` (browser may cache; shared caches must not) possibly with `no-cache` to force revalidation. - If you intentionally edge-cache per-user, the CDN must **key the cache on the identity** (auth cookie/header via `Vary` or cache-key config) and you use `s-maxage` for the edge TTL; get this wrong and you leak data. - Always be explicit — never rely on default heuristics for authenticated endpoints. ## Cross-cutting principals - **Freshness + validation compose**: short `max-age` (no round trip while fresh) + `ETag` (cheap `304` after). Best default for read APIs. - **s-maxage vs max-age**: cache longer at the CDN than in the browser when safe. - **stale-while-revalidate / stale-if-error**: latency hiding and resilience at the edge. - **Strong vs weak ETags**: strong enables range requests and byte-exact validation; weak (`W/`) asserts semantic equivalence and tolerates e.g. compression differences. - **Validator cost is the crux**: `checkNotModified` only pays off if the validator is far cheaper than the body. If computing the ETag requires the full render, you've saved nothing over the shallow filter. - **Observability**: track 304 hit ratio and edge cache-hit ratio to know whether the strategy is actually working. ## Anti-patterns to call out - Slapping `ShallowEtagHeaderFilter` globally including on streaming/large endpoints → OOM and broken streaming. - `public, max-age` on authenticated responses behind a CDN → cross-user data leakage. - Using `Last-Modified` for sub-second-changing resources → missed updates (1s resolution) — use ETags. - Treating `no-cache` as 'don't store' → it still stores; use `no-store` for that.
- Why not just enable ShallowEtagHeaderFilter globally for everything?It buffers the entire response in memory to MD5 it — that breaks streaming and risks OOM on large reports, and it never saves compute. It also can't help privacy for per-user CDN caching. Global use also hashes bodies where a cheap handler validator would save far more.
- What's the security risk of caching per-user responses at a CDN, and how do you avoid it?A shared cache marked public can serve one user's personalized response to another user. Avoid it with Cache-Control: private (or no-store for sensitive data); if you must edge-cache, key the CDN cache on the authenticated identity (Vary/auth-aware cache key) and scope s-maxage carefully.
saying these in an interview costs you the question
- Applying the shallow filter to large/streaming endpoints
- Marking authenticated responses public behind a CDN
- Claiming checkNotModified helps even when the validator requires a full render
- Using Last-Modified for sub-second-changing resources
- Assuming one caching mechanism fits all endpoints