all_lessons / system_design / 06 · caching lesson 6 / 19

Caching, the read-path lever

Most large systems are read-heavy — 100:1 reads-to-writes is unremarkable. A cache is a smaller, faster, second copy of hot data placed closer to the reader. It buys enormous latency and backend-load reduction; it costs you a second copy that can go stale, plus a new failure mode. Both of Karlton's hard things — cache invalidation and naming — bite here.

First principle

If your read:write ratio is R:1 and reads are repetitive (a Zipfian "hot set" — a skewed popularity distribution where a handful of keys get most of the traffic), then a copy of the hot set near the reader serves nearly all traffic. The lever is the hit ratio h: every read you serve from cache is a read the backend never sees and a round-trip the user never waits for. The entire lesson is learning that the value of h is wildly non-linear — the last point of hit rate is worth 10× the first.

Where caches live: a latency ladder

A cache is defined by where it sits, and every tier trades shared-ness against speed. Closer to the reader is faster but less shared (more copies to keep coherent); closer to the data is slower but authoritative. Re-using the latency numbers from lesson 02:

TierTypical latencyShared?Buys / Costs
Client / browser cache~0 (local)Per-userFree round-trip elimination / you can't invalidate it — TTL only
CDN / edge~10–50 ms (geo-local)Per-PoPOffloads static + cacheable dynamic, geo-near / weak control, purge lag
API gateway / reverse proxy~1 msPer-DCCentralized response cache / one more hop to manage
In-process / app-local~100 nsNo (per-node)Nanosecond reads / N incoherent copies — coherence problem
Distributed cache (Redis/Memcached)~0.5 ms (1 net hop)YesShared, coherent-ish, huge / a network hop + a dependency that can fail
DB buffer pool / page cache~0.1 ms (RAM in DB)Per-DBFree, automatic / still behind the DB connection + query parse/plan

The browser and CDN tiers don't expose a delete button — they obey a contract carried in HTTP headers, and it's worth knowing the mechanics. The origin stamps each response with a Cache-Control header that sets the rules: max-age=300 says "this is fresh for 300 seconds, serve it from cache without asking", while no-cache says "you may store it, but revalidate with me before every reuse". Once an entry goes stale, the cache doesn't blindly re-download — it makes a conditional request. The origin's earlier response included a validator, an ETag (a short fingerprint of the body); the cache echoes it back in an If-None-Match header, and if the content hasn't changed the origin replies 304 Not Modified with an empty body. That revalidates the entry cheaply — a tiny round-trip instead of re-sending the whole payload — which is the whole point of the contract. (See 03 for how the same headers shape your API responses.)

The in-process tier is seductive — 100 ns, no network — but it is per-node: with 50 app servers you have 50 copies, and an invalidation must reach all of them or you serve stale. That coherence problem is why a shared distributed tier (0.5 ms) often wins despite being 5000× slower per read: it has one copy to invalidate. The senior move is frequently a two-level (L1 in-process + L2 Redis) cache with short L1 TTLs to bound incoherence.

Read / write patterns

How data enters and leaves the cache relative to the database. Pick by your read:write ratio, your tolerance for stale reads, and whether a crash that loses un-flushed writes is acceptable.

PatternMechanicBuysCosts / risk
Cache-aside (lazy)App reads cache; on miss loads DB, then populates cacheSimple, resilient (cache down ≠ outage), only caches what's readFirst hit is slow (cold miss); stale window between DB write and key expiry/invalidation
Read-throughCache library itself loads from DB on missApp code is cache-agnostic; centralized load logicCouples you to the cache lib; same cold-miss penalty
Write-throughWrite cache + DB synchronouslyCache always fresh; reads never stale post-writeSlower writes (two stores, both on the critical path)
Write-back (write-behind)Write cache, async flush to DBVery fast writes; absorbs write bursts; coalescesData loss on crash before flush; ordering/consistency complexity
Write-aroundWrite DB only; cache fills lazily on later readAvoids caching write-only data that's never re-readRecently-written data is a guaranteed miss (read-after-write is slow)

The default for a generic read-heavy service is cache-aside + write-around with a TTL: it is the most resilient (a cache failure degrades latency, not correctness) and only caches data someone actually reads. Reach for write-through when read-after-write freshness matters; write-back only when write throughput is the bottleneck and you can tolerate a bounded loss window (or back it with a durable log).

The read flow and the economics

READ WRITE (cache-aside + write-around) client ──► cache ──hit?──► return client ──► DB (source of truth) │ │ miss invalidate / │ delete key ▼ │ DB ──► populate cache ──► return ▼ cache fills on next read (miss)

Two formulas carry the entire chapter. With hit ratio h, cache latency t_cache, and miss (backend) latency t_miss:

effective latency ≈ h · t_cache + (1 − h) · t_miss

backend load = (1 − h) · QPS

The backend-load formula is where the non-linearity hides. The miss rate is (1 − h), and it falls geometrically as h approaches 1:

Hit ratio hMiss rate (1−h)Backend QPS @ 50kBackend load vs h=0.90
0.900.105,0001× (baseline)
0.990.0150010× less
0.9990.00150100× less

Going 90% → 99% cuts backend traffic 10×; 99% → 99.9% cuts it another 10×. The last few points of hit rate are worth the most. This is why teams obsess over warming, key design, and TTL tuning long after the cache "works" — they're chasing the geometric tail.

Worked example

Service at 50,000 QPS, t_cache = 0.5 ms, t_miss = 20 ms, hit ratio h = 0.95.

Eviction & working-set fit

A cache is finite; eviction decides what to keep. The policy only matters if your working set doesn't fit — if it does, everything stays and any policy gives ~100% hits.

The economics: hit rate is a function of cache size ÷ working-set size. Because access is Zipfian, the curve is concave — a small cache catches the head cheaply, then you pay a lot of RAM for each additional point. That concavity is the dual of the backend-load non-linearity above: the marginal point of h costs the most RAM and saves the most DB.

Consistency & invalidation

A second copy can disagree with the source of truth. Two strategies, both imperfect:

The honest framing in an interview: "How stale can a read be, and what does that cost the business?" If the answer is "seconds are fine," TTL wins on simplicity. If it's "must be fresh," you pay the invalidation tax (write-through or explicit deletes).

Failure modes (and the fix for each)

A cache adds a new layer that fails in characteristic ways. The recurring shape is the thundering herd — many requests converging on one slow path (forward-ref lesson 13).

FailureWhat happensFix
Stampede / dogpileA hot key expires; thousands of concurrent misses all hit the DB to recompute the same valueSingle-flight / request-coalescing (one loader per key, others wait); serve-stale-while-revalidate; probabilistic early refresh (refresh before TTL with rising probability)
PenetrationRequests for keys that don't exist miss the cache every time and pound the DB (often malicious)Negative caching (cache the "not found" with a short TTL); a Bloom filter of existing keys to reject impossible lookups before the DB
AvalancheMany keys expire at the same instant, or the whole cache tier goes down → full QPS hits the DB at onceJittered TTLs (spread expiry over a window); replicate the cache tier; circuit-break / degrade to a read-only or partial path so the DB survives h → 0
The TTL-jitter reflex

Setting TTL = 300s for a batch of keys loaded together (e.g. on deploy) guarantees they all expire together 5 minutes later — a self-inflicted avalanche. Always set TTL = base + random(0, jitter). The same jitter quietly defuses stampedes too, since hot keys no longer expire in lockstep. It is the single cheapest reliability fix in caching.

Hit-rate economics calculator

Move the hit ratio and watch backend QPS collapse geometrically. The "next point" KPI shows the marginal value of one more percentage point of h — the core senior intuition.

Cache hit-rate economics
Effective latency ≈ h·t_cache + (1−h)·t_miss · · · backend QPS = (1−h)·QPS. Watch the marginal value of +1pp of h as h rises.
Effective avg latency
Backend QPS = (1−h)·QPS
Traffic offloaded
Backend QPS at h+1pp
Read

Interview prompts you should be ready for

  1. "Your read path is at p99 = 40 ms and the DB is the bottleneck. Where do you add a cache and which pattern?" (senior answer: read:write ratio first — if read-heavy, cache-aside + write-around at a shared Redis tier; quantify with effective-latency and backend-load formulas; pick the tier by the shared-vs-speed trade-off, and only go in-process L1 if I can tolerate/bound incoherence with a short TTL.)
  2. "We're at 95% hit rate. Is it worth engineering effort to reach 99%?" (senior answer: yes — backend load drops from (1−0.95)=5% to 1% of QPS, a 5× reduction in DB load and a real latency win, because the miss term dominates the average. The marginal point of h is the most valuable; show the geometric table.)
  3. "A single hot key expires and the DB falls over. What happened and how do you prevent it?" (senior answer: cache stampede — N concurrent misses recompute the same value. Fix with single-flight request coalescing, serve-stale-while-revalidate, and probabilistic early refresh; plus jittered TTLs so hot keys don't expire in lockstep.)
  4. "How do you keep the cache consistent with the database?" (senior answer: you don't, fully — you choose a bounded-staleness model. TTL for tolerant data; explicit invalidation or write-through where read-after-write freshness matters; versioned keys to dodge the delete-everywhere race. The question is always 'how stale is acceptable and what does it cost.')
  5. "Attacker sends requests for random non-existent IDs. Cache hit rate craters. Why and what do you do?" (senior answer: cache penetration — missing keys bypass the cache every time. Negative-cache the not-found with a short TTL and/or front it with a Bloom filter of valid keys so impossible lookups never reach the DB.)
  6. "What's your blast radius if the Redis tier goes down at peak?" (senior answer: h→0 instantly, so the backend faces full QPS — often 10–20× steady state. I must size/shard the DB to survive it, or degrade: shed load, serve stale, circuit-break, replicate the cache tier. Never size the backend for the cached case only.)
  7. "In-process cache vs distributed cache — when and why?" (senior answer: in-process is ~100 ns but per-node, so N incoherent copies and an invalidation that must fan out to all N; distributed is ~0.5 ms but one shared copy to invalidate. Two-level L1+L2 with short L1 TTL gets most of the speed while bounding incoherence.)
  8. "Write-through vs write-back — what would make you choose write-back?" (senior answer: write throughput is the bottleneck and a bounded loss window on crash is acceptable, or it's backed by a durable log. Write-back coalesces and absorbs bursts but trades durability and ordering simplicity; default is write-through/write-around unless writes are the proven hot spot.)
Takeaway

A cache is a faster second copy of hot data, closer to the reader. Its value is governed by two formulas — effective latency ≈ h·t_cache + (1−h)·t_miss and backend load = (1−h)·QPS — and the killer insight is that backend load falls geometrically in h, so the last points of hit rate are worth 10× the first. You pay for this with a second copy that can go stale (choose your bounded-staleness model: TTL, invalidation, or versioned keys) and a new failure tier (stampede, penetration, avalanche — each with a known fix, jittered TTLs being the cheapest). Above all: when the cache dies, h→0 and the full firehose hits the backend, so capacity must survive the un-cached case.