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. Removeprivatefrom the origin (leavingmax-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 withIf-None-Matchand the origin replies304. The 9 KB body crosses the network only once. -
/hello(Vary: Accept-Language): English and French get separate cache entries. WithoutVary, the first language cached would be served to everyone.
Exercises:
- Remove
privatefrom/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). - 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. - 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.
- 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-cacheas "don't cache". It means "revalidate before using". - No
stale-if-error, so a brief origin outage becomes a full site outage.
Check yourself
- What's the difference between
private,no-cache, andno-store? - Why does
s-maxageexist? - What does a
304save, and what does it not save? - 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.
Varyand the cache key decide who gets a cached response. Most caching incidents are key mistakes.stale-while-revalidateandstale-if-errormake caches improve availability, not just speed.