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

Reproduce it

A shared cache that keys by URL only, serving two users; then the same with correct headers:

A shared cache keyed by URL leaks private responsesTry it yourself
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

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-revalidate balances 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: Cookie makes 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 Age header; logs of responses served from cache with Set-Cookie.

Key points

  • Shared caches key by URL: personalized responses need private, no-store.
  • Use max-age + stale-while-revalidate + ETag for public content, immutable for 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.