Frontend Labs
⋯

Chapter 15 — Cache correctness: browser, CDN, proxy, and application caches

In one sentence

Caching is correct only when the cache key, freshness rules, personalization boundaries, invalidation path, and failure behavior are all intentional.

Why this matters

Caching makes the web fast, but it also causes some of the most painful production bugs: one user's data served to another user, stale JavaScript after a deploy, broken pages caused by mixed asset versions, and origin outages hidden until the cache expires. Each cache layer (browser, service worker, CDN, reverse proxy, application) has its own rules. A response travels through all of them.

How it actually works

For every response, answer five questions:

1. Who may cache this?       browser only, shared CDN, proxy, nobody?
2. What is the cache key?    URL, method, Host, Accept-Encoding, headers?
3. How long is it fresh?     max-age, s-maxage, immutable, revalidation?
4. How is it invalidated?    new URL, purge API, surrogate key, TTL only?
5. What happens on failure?  serve stale, revalidate, bypass, fail closed?

The directives that answer them:

Directive Meaning
no-store Nobody stores this, anywhere
private Only the user's browser may store it; shared caches (CDN, proxy) must not
public Shared caches may store it, even if it would normally be uncacheable (e.g. with Authorization)
max-age=N Fresh for N seconds
s-maxage=N Overrides max-age for shared caches only, so the CDN keeps it longer than browsers
no-cache May be stored, but must be revalidated before every use (the name is misleading)
immutable Never changes while fresh; don't revalidate even on reload (for hashed asset URLs)
stale-while-revalidate=N After expiry, serve stale for up to N s while fetching a fresh copy in the background
stale-if-error=N If the origin fails, keep serving stale for up to N s

Revalidation turns "fetch again" into "is my copy still good?". The cache sends If-None-Match: "<etag>" (or If-Modified-Since). If nothing changed, the origin answers 304 Not Modified with no body.

Vary adds request headers to the cache key. Vary: Accept-Encoding keeps gzip and brotli variants apart. Vary: Accept-Language keeps languages apart. Vary: Cookie effectively makes a response uncacheable in shared caches, which is sometimes exactly what you want.

Safer patterns:

# Personalized HTML or API response
Cache-Control: no-store

# Public hashed asset
Cache-Control: public, max-age=31536000, immutable

# CDN cache, browser revalidates quickly
Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=30

# If content varies by encoding or language
Vary: Accept-Encoding, Accept-Language

Build it in Go (60 min) — a CDN edge you can watch make decisions

edgecache is a caching reverse proxy that implements the rules above: no-store, private, max-age, s-maxage, stale-while-revalidate, Vary, "never share a response that sets a cookie", and ETag revalidation. Its -demo mode starts an origin with four endpoints and replays a scripted set of requests:

// edgecache: a tiny CDN edge -- a caching reverse proxy that follows HTTP's
// caching rules: Cache-Control (no-store, private, max-age, s-maxage,
// stale-while-revalidate), Vary, Set-Cookie, ETag revalidation, and Age.
//
//	go run ./edgecache -demo              # starts an origin + the cache, runs a scripted test
//	go run ./edgecache -origin http://localhost:8080 -listen :8081
package main

import (
	"bytes"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"
	"time"
)

type entry struct {
	status  int
	header  http.Header
	body    []byte
	stored  time.Time
	ttl     time.Duration // freshness lifetime
	swr     time.Duration // stale-while-revalidate window
	refresh sync.Once     // at most one background refresh per stale entry
}

type cache struct {
	origin string
	client *http.Client
	mu     sync.Mutex
	items  map[string]*entry
	vary   map[string][]string // base key -> request headers the origin said it varies on
}

func newCache(origin string) *cache {
	return &cache{origin: origin, client: &http.Client{Timeout: 10 * time.Second},
		items: map[string]*entry{}, vary: map[string][]string{}}
}

// key = method + URL + the values of every header named in Vary. Getting the
// key wrong is how caches serve one user's response to another.
func (c *cache) key(r *http.Request) string {
	base := r.Method + " " + r.URL.RequestURI()
	c.mu.Lock()
	vary := c.vary[base]
	c.mu.Unlock()
	var b strings.Builder
	b.WriteString(base)
	for _, h := range vary {
		b.WriteString("|" + h + "=" + r.Header.Get(h))
	}
	return b.String()
}

func (c *cache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	cacheable := (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.Header.Get("Authorization") == ""
	if !cacheable {
		c.forward(w, r, "BYPASS")
		return
	}
	k := c.key(r)
	c.mu.Lock()
	e := c.items[k]
	c.mu.Unlock()

	if e != nil {
		age := time.Since(e.stored)
		switch {
		case age < e.ttl:
			c.serve(w, e, "HIT")
			return
		case age < e.ttl+e.swr:
			c.serve(w, e, "STALE")                                          // fast answer now...
			e.refresh.Do(func() { go c.fetch(r.Clone(r.Context()), k, e) }) // ...fresh copy later
			return
		}
	}
	fresh, state := c.fetch(r, k, e)
	if fresh == nil {
		http.Error(w, "origin unreachable", http.StatusBadGateway)
		return
	}
	c.serve(w, fresh, state)
}

// fetch asks the origin, revalidating with If-None-Match when we hold an old
// copy. A 304 means "your copy is still good": no body crosses the network.
func (c *cache) fetch(r *http.Request, k string, old *entry) (*entry, string) {
	req, _ := http.NewRequest(r.Method, c.origin+r.URL.RequestURI(), nil)
	req.Header = r.Header.Clone()
	if old != nil && old.header.Get("ETag") != "" {
		req.Header.Set("If-None-Match", old.header.Get("ETag"))
	}
	resp, err := c.client.Do(req)
	if err != nil {
		return nil, ""
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode == http.StatusNotModified && old != nil {
		e := &entry{status: old.status, header: old.header, body: old.body, stored: time.Now(), ttl: old.ttl, swr: old.swr}
		c.store(r, k, e, resp.Header)
		return e, "REVALIDATED"
	}
	e := &entry{status: resp.StatusCode, header: resp.Header.Clone(), body: body, stored: time.Now()}
	ttl, swr, ok := policy(resp)
	if !ok {
		return e, "UNCACHEABLE" // pass it through to this client, store nothing
	}
	e.ttl, e.swr = ttl, swr
	c.store(r, k, e, resp.Header)
	return e, "MISS"
}

func (c *cache) store(r *http.Request, k string, e *entry, h http.Header) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if v := h.Get("Vary"); v != "" {
		base := r.Method + " " + r.URL.RequestURI()
		var hs []string
		for _, f := range strings.Split(v, ",") {
			hs = append(hs, http.CanonicalHeaderKey(strings.TrimSpace(f)))
		}
		c.vary[base] = hs
		k = c.keyLocked(r, hs)
	}
	c.items[k] = e
}

func (c *cache) keyLocked(r *http.Request, vary []string) string {
	k := r.Method + " " + r.URL.RequestURI()
	for _, h := range vary {
		k += "|" + h + "=" + r.Header.Get(h)
	}
	return k
}

// policy decides whether a SHARED cache may store a response, and for how long.
func policy(resp *http.Response) (ttl, swr time.Duration, ok bool) {
	if resp.StatusCode != http.StatusOK || resp.Header.Get("Set-Cookie") != "" {
		return 0, 0, false // never share a response that sets someone's cookie
	}
	cc := map[string]string{}
	for _, d := range strings.Split(resp.Header.Get("Cache-Control"), ",") {
		k, v, _ := strings.Cut(strings.TrimSpace(strings.ToLower(d)), "=")
		cc[k] = v
	}
	if _, ok := cc["no-store"]; ok {
		return 0, 0, false
	}
	if _, ok := cc["private"]; ok {
		return 0, 0, false // browser may cache it; a CDN must not
	}
	if v, ok := cc["vary"]; ok && v == "*" {
		return 0, 0, false
	}
	secs := func(name string) (time.Duration, bool) {
		v, ok := cc[name]
		if !ok {
			return 0, false
		}
		n, err := strconv.Atoi(v)
		return time.Duration(n) * time.Second, err == nil
	}
	if d, ok := secs("s-maxage"); ok { // s-maxage wins for shared caches
		ttl = d
	} else if d, ok := secs("max-age"); ok {
		ttl = d
	} else {
		return 0, 0, false // no explicit freshness: don't guess
	}
	swr, _ = secs("stale-while-revalidate")
	return ttl, swr, true
}

func (c *cache) serve(w http.ResponseWriter, e *entry, state string) {
	for k, v := range e.header {
		w.Header()[k] = v
	}
	w.Header().Set("Age", strconv.Itoa(int(time.Since(e.stored).Seconds())))
	w.Header().Set("X-Cache", state)
	w.WriteHeader(e.status)
	w.Write(e.body)
}

func (c *cache) forward(w http.ResponseWriter, r *http.Request, state string) {
	req, _ := http.NewRequest(r.Method, c.origin+r.URL.RequestURI(), r.Body)
	req.Header = r.Header.Clone()
	resp, err := c.client.Do(req)
	if err != nil {
		http.Error(w, "origin unreachable", http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()
	for k, v := range resp.Header {
		w.Header()[k] = v
	}
	w.Header().Set("X-Cache", state)
	w.WriteHeader(resp.StatusCode)
	io.Copy(w, resp.Body)
}

// ------------------------------------------------------------------ demo --

func demoOrigin(hits map[string]int, mu *sync.Mutex) http.Handler {
	count := func(r *http.Request) { mu.Lock(); hits[r.URL.Path]++; mu.Unlock() }
	mux := http.NewServeMux()
	mux.HandleFunc("/static.js", func(w http.ResponseWriter, r *http.Request) {
		count(r)
		w.Header().Set("Cache-Control", "public, max-age=2, stale-while-revalidate=5")
		fmt.Fprint(w, "console.log('v1')")
	})
	mux.HandleFunc("/account", func(w http.ResponseWriter, r *http.Request) {
		count(r)
		w.Header().Set("Cache-Control", "private, max-age=60")
		fmt.Fprintf(w, "balance for %s", r.Header.Get("X-User"))
	})
	mux.HandleFunc("/news", func(w http.ResponseWriter, r *http.Request) {
		count(r)
		w.Header().Set("Cache-Control", "public, max-age=1")
		w.Header().Set("ETag", `"news-v7"`)
		if r.Header.Get("If-None-Match") == `"news-v7"` {
			w.WriteHeader(http.StatusNotModified)
			return
		}
		fmt.Fprint(w, strings.Repeat("big news ", 1000))
	})
	mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		count(r)
		w.Header().Set("Cache-Control", "public, max-age=60")
		w.Header().Set("Vary", "Accept-Language")
		if strings.HasPrefix(r.Header.Get("Accept-Language"), "fr") {
			fmt.Fprint(w, "bonjour")
			return
		}
		fmt.Fprint(w, "hello")
	})
	return mux
}

func main() {
	demo := flag.Bool("demo", false, "run a scripted demo against a built-in origin")
	origin := flag.String("origin", "", "origin base URL")
	listen := flag.String("listen", ":8081", "listen address")
	flag.Parse()
	if !*demo {
		log.Printf("caching %s on %s", *origin, *listen)
		log.Fatal(http.ListenAndServe(*listen, newCache(*origin)))
	}

	hits := map[string]int{}
	var mu sync.Mutex
	o := httptest.NewServer(demoOrigin(hits, &mu))
	defer o.Close()
	edge := httptest.NewServer(newCache(o.URL))
	defer edge.Close()

	get := func(path string, hdr ...string) {
		req, _ := http.NewRequest("GET", edge.URL+path, nil)
		for i := 0; i+1 < len(hdr); i += 2 {
			req.Header.Set(hdr[i], hdr[i+1])
		}
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			fmt.Println(err)
			return
		}
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		mu.Lock()
		n := hits[path]
		mu.Unlock()
		fmt.Printf("GET %-11s %-28s -> %-12s age=%-2s origin hits=%d  body=%q\n", path, strings.Join(hdr, " "),
			resp.Header.Get("X-Cache"), resp.Header.Get("Age"), n, string(bytes.TrimSpace(b[:min(len(b), 20)])))
	}

	get("/static.js")
	get("/static.js")
	time.Sleep(2100 * time.Millisecond)
	get("/static.js") // stale: served instantly, refreshed in the background
	time.Sleep(100 * time.Millisecond)
	get("/static.js")
	get("/account", "X-User", "alice")
	get("/account", "X-User", "bob")
	get("/news")
	time.Sleep(1100 * time.Millisecond)
	get("/news") // stale, no swr: revalidate with If-None-Match -> 304
	get("/hello", "Accept-Language", "en")
	get("/hello", "Accept-Language", "fr")
	get("/hello", "Accept-Language", "fr")
}
$ go run ./edgecache -demo
GET /static.js                               -> MISS         age=0  origin hits=1  body="console.log('v1')"
GET /static.js                               -> HIT          age=0  origin hits=1  body="console.log('v1')"
GET /static.js                               -> STALE        age=2  origin hits=1  body="console.log('v1')"
GET /static.js                               -> HIT          age=0  origin hits=2  body="console.log('v1')"
GET /account    X-User alice                 -> UNCACHEABLE  age=0  origin hits=1  body="balance for alice"
GET /account    X-User bob                   -> UNCACHEABLE  age=0  origin hits=2  body="balance for bob"
GET /news                                    -> MISS         age=0  origin hits=1  body="big news big news bi"
GET /news                                    -> REVALIDATED  age=0  origin hits=2  body="big news big news bi"
GET /hello      Accept-Language en           -> MISS         age=0  origin hits=1  body="hello"
GET /hello      Accept-Language fr           -> MISS         age=0  origin hits=2  body="bonjour"
GET /hello      Accept-Language fr           -> HIT          age=0  origin hits=2  body="bonjour"

Read it line by line:

  • /static.js (max-age=2, stale-while-revalidate=5): MISS, then HIT. After 2 s it's STALE: the client gets an instant answer while one background refresh runs (origin hits go 1 → 2), and the next request is a fresh HIT. Users never wait for the origin.

  • /account (private): never stored. Alice and Bob each get their own answer. Remove private from the origin (leaving max-age=60) and run it again:

    GET /account    X-User alice    -> MISS   origin hits=1  body="balance for alice"
    GET /account    X-User bob      -> HIT    origin hits=1  body="balance for alice"
    

    Bob receives Alice's balance. That's the real-world bug below, reproduced with one missing word.

  • /news (ETag, max-age=1): once stale, the edge revalidates with If-None-Match and the origin replies 304. The 9 KB body crosses the network only once.

  • /hello (Vary: Accept-Language): English and French get separate cache entries. Without Vary, the first language cached would be served to everyone.

Exercises:

  1. Remove private from /account, rerun, and watch the leak. Then fix it two ways: in the origin (private/no-store) and in the cache (never store responses to requests carrying a session cookie).
  2. Add stale-if-error: if the origin returns 5xx or is unreachable, serve the stale copy. Kill the origin and check that the site stays up.
  3. Add request collapsing: when 100 requests miss on the same key at once, send one request to the origin and share the result. Without it, a popular object expiring causes a "thundering herd" on the origin.
  4. Cache poisoning: add a header the origin reflects into the body (X-Forwarded-Host, say) but that is not in the cache key. Show that one request poisons the cached copy for everyone. The fix is to put such headers in the key, or strip them at the edge.

Real-world scenario

An API response includes user-specific recommendations but lacks Cache-Control: private or no-store. A CDN caches it by URL and serves one user's personalized response to another. The bug is not "the CDN is bad"; the origin failed to define the cache boundary. A related attack, web cache deception, tricks a cache into storing a private page under a static-looking URL (/account/profile.css) that the attacker then fetches. Defend by caching by content type and explicit headers, never by file extension alone.

Common mistakes

  • Caching authenticated responses in shared caches without a deliberate key.
  • Using query-string versioning inconsistently instead of content-hashed asset filenames.
  • Forgetting Vary, causing compressed/language-specific responses to be reused for the wrong clients.
  • Purging CDN cache but leaving browser cache or service-worker cache untouched.
  • Reading no-cache as "don't cache". It means "revalidate before using".
  • No stale-if-error, so a brief origin outage becomes a full site outage.

Check yourself

  1. What's the difference between private, no-cache, and no-store?
  2. Why does s-maxage exist?
  3. What does a 304 save, and what does it not save?
  4. How can a header that isn't part of the cache key poison a cache?

Key Takeaways

  • Every response needs an explicit caching decision. The default behaviours differ between layers.
  • Vary and the cache key decide who gets a cached response. Most caching incidents are key mistakes.
  • stale-while-revalidate and stale-if-error make caches improve availability, not just speed.

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36