C.W.K.
Stream
Lesson 01 of 05 · published

HTTP Cache Headers — The Contract Servers Speak to Intermediaries

~11 min · caching-perf, cache-control, etag, last-modified

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Caching isn't something you add on top — it's a protocol the server speaks fluently or accidentally breaks. Get the headers right and CDNs, browsers, and reverse proxies all cooperate. Get them wrong and you ship stale data to one user and miss-cache another user's auth response."

The Five Headers That Run Caching

  • Cache-Control — the modern, comprehensive directive. Everything else is a fallback or refinement.
  • ETag — opaque version tag for conditional revalidation (Track 2 Lesson 4).
  • Last-Modified — date-based validator, used with If-Modified-Since.
  • Expires — absolute expiration date. Pre-HTTP/1.1; overridden by Cache-Control when both present.
  • Vary — tells caches which request headers to add to the cache key.

Cache-Control Directives You Actually Use

Cache-Control values are comma-separated directives. The combinations matter:

  • max-age=N — fresh for N seconds. Caches may serve without re-checking.
  • s-maxage=N — like max-age but for SHARED caches only (CDN, proxy). Overrides max-age for them. Lets you set short browser TTL + long CDN TTL.
  • public — any cache may store this response. Required for CDN caching of authenticated responses.
  • private — only the requesting browser (private cache) may store. CDNs and proxies must not cache.
  • no-cache — store it, but ALWAYS revalidate before serving (send conditional GET). Misleadingly named; it doesn't mean "don't cache."
  • no-store — actually don't cache. Period. For sensitive data, payment forms, anything that must not persist.
  • must-revalidate — once expired, MUST revalidate; don't serve stale even if the network is down.
  • stale-while-revalidate=N — serve stale for N seconds while revalidating in the background. Modern, browser-supported, great UX win.
  • immutable — this response will never change in its lifetime. Skip revalidation entirely. Use on fingerprinted asset URLs (app.abc123.css).

The Three Patterns You Reach For Most

1. Versioned static assets (CSS, JS, images with hash in filename):

Cache-Control: public, max-age=31536000, immutable

A year, never revalidate. Safe because the filename changes when the content changes — clients fetch the new URL.

2. Frequently-changing API responses (lists, user dashboards):

Cache-Control: private, max-age=60, must-revalidate
ETag: "v17-abc"
Vary: Accept-Encoding, Authorization

Brief browser cache, conditional GET after 60s, never cached by CDN. The ETag enables 304 responses; the Vary keeps per-user data separated.

3. Personal / sensitive data (auth responses, payment forms):

Cache-Control: no-store

Never cached anywhere. The only safe choice for tokens, session data, anything that must not persist on disk.

The default is silently wrong. An API endpoint with no Cache-Control header gets cached by intermediaries based on heuristics — sometimes for minutes, sometimes for hours. ALWAYS set Cache-Control explicitly, even if it's just no-store or private, max-age=0. Implicit caching is where production data leaks live.

cwkPippa's Cache Headers

cwkPippa's API endpoints default to Cache-Control: no-store — its data is per-user and the WebUI is the only client. Static assets served by Vite get Cache-Control: public, max-age=31536000, immutable on hashed bundles (default Vite output) and no-cache on index.html so deploys take effect immediately. cwk-site's blog posts get public, s-maxage=300, stale-while-revalidate=86400 — short CDN freshness for editorial control, long stale-allowed for resilience. The pattern matches the data's freshness needs.

Code

FastAPI: per-response Cache-Control matched to the data's freshness model·python
# FastAPI — set Cache-Control per response, not globally
from fastapi import FastAPI, Response

app = FastAPI()

@app.get('/api/health')
async def health(response: Response):
    response.headers['Cache-Control'] = 'public, max-age=10'
    return {'ok': True}

@app.get('/api/me')
async def me(response: Response):
    # Per-user data — never share-cache
    response.headers['Cache-Control'] = 'private, no-store'
    return {'user_id': 'u_42'}

@app.get('/api/posts')
async def list_posts(response: Response):
    # Listings change often — short cache, ETag for revalidation, Vary for auth
    response.headers['Cache-Control'] = 'private, max-age=60, must-revalidate'
    response.headers['ETag']          = '"v17-abc"'
    response.headers['Vary']          = 'Accept-Encoding, Authorization'
    return {'items': [...]}

@app.get('/assets/app.abc123.css')
async def static_asset(response: Response):
    # Hashed filename — content can't change; cache forever
    response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
    return Response('body{...}', media_type='text/css')
Three canonical Cache-Control patterns on the wire·http
# Three canonical Cache-Control patterns

# 1. Versioned static asset — cache forever
GET /assets/app.abc123.css HTTP/1.1

HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: public, max-age=31536000, immutable

# 2. API listing — brief cache + conditional revalidation + Vary
GET /api/posts HTTP/1.1
Authorization: Bearer abc

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60, must-revalidate
ETag: "v17-abc"
Vary: Accept-Encoding, Authorization

# 3. Sensitive personal data — never cache
GET /api/me HTTP/1.1
Authorization: Bearer abc

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store
Browser respects server Cache-Control; client can override per fetch·javascript
// Client side — fetch with Cache API + stale-while-revalidate behavior
// (Modern browsers automatically handle stale-while-revalidate from server headers)

// Just fetch normally; the browser respects Cache-Control
const resp = await fetch('/api/posts');
const data = await resp.json();

// You can also opt into a specific cache policy from the client
const freshOnly = await fetch('/api/posts', { cache: 'no-cache' });    // always revalidate
const neverNetwork = await fetch('/api/posts', { cache: 'force-cache' }); // serve cache if any
const noCache = await fetch('/api/posts', { cache: 'no-store' });      // skip the cache entirely

// Service Worker for fine-grained control
// (not detailed here — fetch event + caches.match + cache.put pattern)

External links

Exercise

Build three endpoints in FastAPI with three different Cache-Control patterns: (1) /static/asset.css with public, max-age=31536000, immutable, (2) /api/posts with private, max-age=60, must-revalidate + ETag + Vary, (3) /api/me with no-store. Hit each with curl -i and read the headers. Then call them in succession from a browser DevTools session and watch the Network tab show 304 (for #2 on revalidation) and 'from cache' (for #1). Bonus: deliberately omit Cache-Control on a fourth endpoint and watch how Chrome/Firefox guess.
Hint
FastAPI's Response object lets you set headers inline. ETag value can be any string in quotes — for the demo, hardcode "v17-abc". The browser DevTools 'Network' panel shows the cache status per request (200 from network, 304 not modified, from disk cache, from memory cache). The fourth-endpoint experiment shows why explicit caching always beats implicit: each browser's heuristics differ, and you can't reason about behavior you didn't declare.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.