The Caching Stack: From Browser to Buffer Pool
Five layers walked top to bottom — browser headers, CDN edge with its famous incident, Redis with a job description, database-adjacent options — plus the staleness grid that makes TTLs a product decision.
When someone says "we added caching", my first question is always "which caching?" — because a modern web request passes through at least five places where a cache could answer it, and they have wildly different economics. Confusing them is how teams end up with a Redis cluster doing work a CDN would do for free, or a CDN cheerfully serving user A's account page to user B (the genre's most famous incident, reproduced monthly somewhere on Earth). Let's walk the request path top to bottom and put each layer to its right work.
Layer 1: the browser — the cache you configure with headers
The fastest request is one that never leaves the device. For fingerprinted static assets (Vite's app-8f3ka92.css), the answer is maximal and safe: Cache-Control: public, max-age=31536000, immutable — the filename changes when content does, so staleness is structurally impossible. For HTML documents, the opposite: no-cache (which means "revalidate", not "don't store") so deploys propagate instantly. The middle ground most teams miss: stale-while-revalidate, letting browsers serve slightly-old responses while refreshing in the background — free perceived latency for anything tolerant of seconds of staleness.
Layer 2: the CDN — free capacity, sharpest edges
The CDN absorbs traffic before it costs you compute — static assets trivially, and with care, whole anonymous pages: a blog like this one, product pages, landing pages can serve from edge nodes at effectively infinite scale. The two disciplines that keep it safe:
- Never cache personalized responses at the edge without surgical key design. The B-sees-A's-page incident is always the same root cause: a
Set-Cookie-bearing or session-varying response cached under a shared key. Blunt rule that's saved me repeatedly: authenticated traffic bypasses edge caching entirely (Cache-Control: private), and the edge caches only routes on an explicit allowlist. - Purge-by-tag or short TTLs — cache tags (Cloudflare, Fastly) let a publish event purge exactly the affected URLs; without them, keep edge TTLs short (60–300s) plus
stale-while-revalidate, which for most content sites is indistinguishable from perfect invalidation at a fraction of the machinery.
Layer 3: the application — Redis, the workhorse with a job description
This is where the dedicated Redis post lives, so here's just the placement logic: the application cache exists for expensive computations over your own data — rendered fragments, aggregated dashboards, permission trees, external API responses. It is not for things an upper layer could hold (anonymous full pages → CDN) nor a bandage for things a lower layer should fix (a 900ms query that needs an index, not a cache — caching an unindexed query means the p99 on every miss is still 900ms, now with extra mystery). The honest test before adding any app-cache key: could the layer above hold this, or the layer below make this fast enough not to need holding? Only between those two answers does Redis earn the key.
Layer 4: database-adjacent caching — mostly, don't
MySQL's query cache is gone (removed in 8.0, deservedly — its invalidation granularity made it a contention machine), and the modern equivalents live elsewhere: the database's own buffer pool (which is why "the database is fast after warmup" — tune its size, it's your realest cache), materialized views / summary tables for heavy aggregations (the read-model pattern — refreshed on schedule or by events, honest about staleness), and read replicas — which aren't caches at all but absorb the same read pressure, with replication-lag semantics you must respect (read-your-own-writes routes to primary).
The cross-cutting law: staleness is a product decision
Every layer's TTL answers the same question — how stale is acceptable? — and that answer belongs to the product, not the infrastructure. My planning grid for any cacheable thing:
Data Tolerance Layer(s) Invalidation
─────────────────────────────────────────────────────────────────────────────
Fingerprinted assets infinite browser + CDN filename
Blog/article pages minutes CDN (+ SWR) tag purge on publish
Product listing ~60s CDN short-TTL + app cache version stamp
User dashboard seconds app cache only event-driven delete
Account/auth/checkout zero NO caching, ever n/a
Search results ~30s app cache, keyed on query TTL only
Two rows deserve underlining. The "zero tolerance" row exists — some responses must never be cached anywhere, and marking them explicitly (private, no-store) is as important as caching the rest. And every row needs an owner for its invalidation story — a cache with no invalidation plan isn't a cache, it's a scheduled incident. Stack the layers with those two rules and you get the compounding win: the CDN absorbs the flood, Redis absorbs the computation, the buffer pool absorbs the reads — and the database finally gets to do only the work that's genuinely novel. Which was the whole idea.
Site slow despite "having caching"? The audit that finds which layer is missing — or lying — is a one-day engagement with before/after graphs included.