HTTP Caching: Cache-Control, ETags, Last-Modified
Fast recall
Quick revision
Core answer
max-age, public, private, no-cache, no-store, immutable) tell caches how long a response stays fresh without revalidation; validation (ETag, Last-Modified + If-None-Match/If-Modified-Since) lets a cache check with origin cheaply — 304 Not Modified if unchanged, saving bandwidth. Use together: set Cache-Control: max-age=300, public for fresh reuse; set ETag so stale caches revalidate cheaply. In Spring Boot: use ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES)).eTag(etag).body(...) or ShallowEtagHeaderFilter for auto-ETags; combine with CDN (CloudFront, Cloudflare) and compression (Brotli > gzip) for real production wins.Deep understanding of HTTP semantics; practical caching strategy; real performance engineering
Setting Cache-Control: no-cache everywhere (disables caching); returning wrong Vary headers (poisons CDN); generating weak ETags on dynamic data (no real savings)
Discuss public vs private, stale-while-revalidate, stale-if-error, CDN integration, Vary header pitfalls, conditional requests, Brotli vs gzip, and cache key design
Interview-ready guide
Interview answer
HTTP caching reduces origin load, tail latency, and bandwidth by reusing responses. The HTTP/1.1 RFC defines two complementary mechanisms.
Freshness — how long can I reuse without asking the server?
- Set by Cache-Control max-age in seconds
- While fresh, cache serves without contacting origin
- Expires header is the older alternative
Validation — this is stale, is it still good?
- Set by ETag (opaque content hash) or Last-Modified timestamp
- Cache sends conditional request with If-None-Match or If-Modified-Since
- Origin returns 304 Not Modified if unchanged (no body, cheap) or 200 with new body if changed
Combined they give the right behavior
- max-age=60 plus ETag: fresh for 1 minute, then cheap revalidation
- Matches real-world data patterns
Cache-Control directives that matter
- public: any cache may store (CDN, proxy, browser)
- private: only browser; never CDN
- max-age=N: fresh for N seconds
- s-maxage=N: overrides max-age in shared caches (CDN)
- no-cache: may cache but must revalidate before serving
- no-store: don't cache at all (secrets, PII)
- must-revalidate: don't serve stale even if origin unreachable
- immutable: content never changes (versioned assets)
- stale-while-revalidate=N: serve stale for N seconds while revalidating in background
- stale-if-error=N: serve stale for N seconds if origin 5xx
The three layers of cache
- Browser cache: per user
- CDN: CloudFront, Cloudflare, Fastly; shared at edge
- Origin reverse proxy: nginx, Varnish
- All see the same headers; good design uses all three
Common mistakes
no-cache is not 'no caching'
- no-cache means 'store but revalidate before serving'
- no-store means 'don't cache at all'
- Huge difference; use no-store for PII and secrets
public on user-specific data
- CDN will serve one user's data to another
- Always private + Vary: Authorization for user-specific
Vary header pitfalls
- Vary: * effectively disables caching
- Vary: User-Agent splits cache into hundreds of variants
- Be precise: Accept-Encoding cheap; Authorization needed for per-user
ETag generation cost
- Content hash on every request means no CPU savings
- Better: version plus updated_at (O(1) generation)
- ShallowEtagHeaderFilter is easy but computes hash from full body
Stale-while-revalidate is a game changer
- Fresh 30 seconds; during next 5 minutes serve stale immediately, revalidate in background
- User never waits for revalidation
- p99 latency drops dramatically
- Supported by modern browsers and CDNs
Stale-if-error for resilience
- If origin returns 5xx during revalidation, serve stale for additional N seconds
- Buys headroom during incidents
- Combined with SWR, survives minor blips transparently
Spring Boot patterns
Static assets with versioned URLs
- main.abc123.js with max-age=1 year, immutable, public
- Change content means change URL; no invalidation needed
API responses with ETag
- ResponseEntity.ok().cacheControl(...).eTag(etag).body(p)
- WebRequest.checkNotModified(etag) compares If-None-Match and returns 304
User-specific data
- CacheControl.maxAge(60, SECONDS).cachePrivate()
- Vary: Authorization
- Browser caches per user; CDN doesn't cache
Stale-while-revalidate
- CacheControl.maxAge(30s).staleWhileRevalidate(5min).staleIfError(30min)
- Huge p99 wins for low-volume dynamic data
Compression
- gzip universal; 70% reduction on JSON
- Brotli 20 to 30% better than gzip; all modern browsers support
- Don't compress under 1KB; overhead exceeds savings
- Don't compress images or video; already compressed
- Vary: Accept-Encoding to cache per compression type
CDN integration
- Default cache key is URL plus Vary headers
- Invalidation by URL pattern; slow and expensive
- Prefer versioned URLs over manual invalidation
- Target CDN hit rate above 80% for public content
Cache key design
- Vary: Accept-Encoding is cheap and valuable
- Vary: Authorization for per-user cached content
- Avoid Vary: User-Agent unless you handle dozens of variants
- Query string handling configurable in CloudFront/Cloudflare
The production pattern
- Static assets: max-age=1 year, immutable, versioned URLs
- Rarely-changing API: max-age=5 min, public, ETag, stale-while-revalidate
- User-specific data: max-age=1 min, private, Vary: Authorization
- Auth tokens and PII: no-store
- Tune per endpoint; measure hit rates; adjust
Observability
- CDN hit rate (target > 80% for public content)
- Origin request rate (inverse of effectiveness)
- 304 response rate (shows validation working)
- Response size with and without compression
The strategic answer
- Cache-Control for freshness; ETag or Last-Modified for validation
- CDN with well-tuned headers is the cheapest scaling win
- stale-while-revalidate plus stale-if-error for resilience
- Versioned URLs for immutable assets
- Measure hit rates; tune; repeat
- Well-cached API serves 99% of traffic from edge without touching your Spring Boot servers
HTTP Caching: Cache-Control, ETags, Last-Modified
Direct Answer
HTTP caching has two main mechanisms: freshness (Cache-Control directives like max-age, public, private, no-cache, no-store, immutable) tell caches how long a response stays fresh without revalidation; validation (ETag, Last-Modified + If-None-Match/If-Modified-Since) lets a cache check with origin cheaply — 304 Not Modified if unchanged, saving bandwidth. Use together: set Cache-Control: max-age=300, public for fresh reuse; set ETag so stale caches revalidate cheaply. In Spring Boot: use ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES)).eTag(etag).body(...) or ShallowEtagHeaderFilter for auto-ETags; combine with CDN (CloudFront, Cloudflare) and compression (Brotli > gzip) for real production wins.
Common Mistakes & Pitfalls
Setting Cache-Control: no-cache everywhere (disables caching); returning wrong Vary headers (poisons CDN); generating weak ETags on dynamic data (no real savings)
Check your understanding
Answer these without looking back at the article.
- How does stale-while-revalidate compare to setting a longer max-age?
- When would you choose ShallowEtagHeaderFilter vs manual ETag generation?
- How does Cloudflare handle Cache-Control: private vs CloudFront?
- What are the security implications of caching authenticated responses?
Related interview prep categories
1Want a guided curriculum order?
These modules are also sequenced in our flagship backend interview track — open it when you prefer an ordered path instead of browsing by topic.