Database Caching at Scale · Cache Consistency Strategies
HTTP and CDN Caching Without Surprises
Cache-Control, ETags, stale-while-revalidate and purges — and the headers that keep private data out of shared caches.
Scenario
Example app: a store adds a CDN in front of its API and pages. Product pages are public; /api/cart and /account are per user. The CDN is configured to cache all GET responses for 5 minutes "for performance".
What you see in production
A customer reports seeing someone else's cart and name in the header. Product price changes take up to 5 minutes to appear, and the "purge" button in the CDN console is pressed many times a day.
The naive code
@app.get("/api/cart")
def cart(user=Depends(current_user)):
return cart_service.for_user(user.id) # no Cache-Control → the CDN's default rule caches it
Cache everything (all GET requests) for 300 s, ignore Cache-Control
Reproduce it
A shared cache that keys by URL only, serving two users; then the same with correct headers:
def shared_cache(requests, respect_private):
cache, served = {}, []
for user, url in requests:
response, cache_control = (f"cart of {user}", "private, no-store") if url == "/api/cart" \
else ("product page", "public, max-age=60")
if url in cache:
served.append((user, cache[url] + " (from CDN)"))
continue
if not (respect_private and "private" in cache_control):
cache[url] = response
served.append((user, response))
return served
reqs = [("alice", "/api/cart"), ("bob", "/api/cart"), ("bob", "/products/7")]
for respect in (False, True):
print("respects Cache-Control:" if respect else "caches everything:")
for user, body in shared_cache(reqs, respect):
print(f" {user:5} -> {body}")
Output
caches everything: alice -> cart of alice bob -> cart of alice (from CDN) bob -> product page respects Cache-Control: alice -> cart of alice bob -> cart of bob bob -> product page
Root cause
A shared cache (CDN, reverse proxy) keys responses by URL (plus whatever Vary lists). Personalized responses without Cache-Control: private/no-store — or with a CDN rule that ignores headers — get served to the next visitor. On the other end, public pages cached with long max-age and no purge or revalidation strategy stay stale.
The fix
Be explicit on every response:
| Content | Header |
|---|---|
| Personalized (cart, account, API with auth) | Cache-Control: private, no-store |
| Public, changes occasionally | Cache-Control: public, max-age=60, stale-while-revalidate=300 + ETag |
Fingerprinted static assets (app.3f9a.js) |
Cache-Control: public, max-age=31536000, immutable |
@app.get("/api/cart")
def cart(response: Response, user=Depends(current_user)):
response.headers["Cache-Control"] = "private, no-store"
return cart_service.for_user(user.id)
@app.get("/products/{pid}")
def product(pid: int, request: Request, response: Response):
p = products.get(pid)
etag = f'"{p.version}"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
response.headers.update({"Cache-Control": "public, max-age=60, stale-while-revalidate=300", "ETag": etag})
return p
@GetMapping("/products/{id}")
public ResponseEntity<ProductDto> product(@PathVariable long id) {
ProductDto p = products.get(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofSeconds(60)).cachePublic().staleWhileRevalidate(Duration.ofMinutes(5)))
.eTag(String.valueOf(p.version()))
.body(p);
}
return response()->json($cart)->header('Cache-Control', 'private, no-store');
return response()->json($product)
->header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
->setEtag((string) $product->version);
res.set("Cache-Control", "private, no-store").json(cart);
res.set({ "Cache-Control": "public, max-age=60, stale-while-revalidate=300", ETag: `"${p.version}"` }).json(p);
Configure the CDN to respect origin headers, never cache responses with Set-Cookie or Authorization by default, and purge by URL or surrogate key on important changes.
Trade-offs
- Short
max-age+stale-while-revalidatebalances freshness and origin load without purges for most content. - Purges are powerful but slow to propagate globally and easy to overuse; surrogate keys (tags) let you purge "everything for product 7".
Vary: Cookiemakes responses effectively uncacheable at the CDN — split personalized parts out (load them client-side) instead.
Detect it in production
- Automated checks: authenticated endpoints must return
private/no-store; fail CI if not. - CDN hit ratio per path and the
Ageheader; logs of responses served from cache withSet-Cookie.
Key points
- Shared caches key by URL: personalized responses need
private, no-store. - Use
max-age+stale-while-revalidate+ETagfor public content,immutablefor fingerprinted assets. - Make the CDN respect origin headers; purge selectively.
Practice questions
Why can a CDN show one customer's cart to another?
Answer
A shared cache keyed by URL stored a personalized response because it lacked Cache-Control: private/no-store (or the CDN ignored headers), then served it to the next visitor of the same URL.
What does `Cache-Control: public, max-age=60, stale-while-revalidate=300` mean?
Answer
Shared and private caches may store it; it's fresh for 60 s; for the next 300 s a cache may serve it stale while it revalidates in the background.
When is `immutable` appropriate?
Answer
For fingerprinted static assets whose URL changes whenever the content changes (e.g. app.3f9a.js), so browsers never need to revalidate them.
Open in the app to mark lessons complete, take notes and track your progress.