Day 5 — Caching Fundamentals
Goal: Own the biggest single lever in system design. By the end you should be able to: - Explain why caching wins on latency, throughput, and cost — with the math - Place caches correctly across the 6-layer cache cake (browser → CDN → gateway → app → distributed → DB) - Choose between cache-aside / write-through / write-back / read-through and explain the trade-offs - Pick an eviction policy (LRU / LFU / TTL) with reasoning - Reason about hit rate and its outsized impact on effective latency - Anticipate cache consistency, stale reads, and thundering herd failure modes
Estimated time: 50–60 min read + reflection.
1. Why caching wins — the fundamental economics
Caching answers three problems at once:
- Latency — cached reads are typically 100–10,000× faster than the backing store.
- Throughput — every cache hit is a request that doesn't touch your DB. A 90% hit rate means your DB sees 10% of the traffic — 10× the effective capacity.
- Cost — DB reads are expensive (CPU, IOPS, licenses). Cache reads are cheap RAM ops. Turning DB QPS into cache QPS is one of the highest-leverage cost-savers in engineering.
The effective-latency equation
For a cache-fronted read path:
[ L_{effective} = h \cdot L_{cache} + (1 - h) \cdot L_{miss} ]
Where: - h = hit rate (0 to 1) - L_cache = latency of a cache hit (typical: 1 ms for Redis in-DC) - L_miss = latency of a cache miss (cache + DB + often cache write) — typical: 20–100 ms
Worked example. DB read = 50 ms, cache hit = 1 ms. - At 0% hit rate: effective = 50 ms (cache is dead weight) - At 50% hit rate: effective = 0.5 × 1 + 0.5 × 50 = 25.5 ms (2× faster) - At 90% hit rate: effective = 0.9 × 1 + 0.1 × 50 = 5.9 ms (8× faster) - At 99% hit rate: effective = 0.99 × 1 + 0.01 × 50 = 1.49 ms (33× faster)
Hit rate is not linear in its effect. Small improvements at the top of the curve matter enormously.
2. The 6-layer cache cake
Real systems cache in many places at once. When someone asks "where should we cache?" the answer is usually "yes — all of these":
┌─────────────────────────────────────────────────────┐
│ 1. Browser cache (Cache-Control, ETag) │
├─────────────────────────────────────────────────────┤
│ 2. CDN / edge cache (CloudFront, Cloudflare) │
├─────────────────────────────────────────────────────┤
│ 3. Reverse proxy cache (Varnish, NGINX) │
├─────────────────────────────────────────────────────┤
│ 4. Application cache (in-process: Caffeine) │
├─────────────────────────────────────────────────────┤
│ 5. Distributed cache (Redis, Memcached) │
├─────────────────────────────────────────────────────┤
│ 6. Database's own cache (buffer pool, plan cache) │
└─────────────────────────────────────────────────────┘
↓
Storage
Layer-by-layer
1. Browser cache — client keeps assets in local memory/disk based on HTTP headers (Cache-Control: max-age=3600, ETag, Last-Modified).
- What to cache: static assets (CSS, JS, images, fonts), sometimes JSON responses.
- Wins: 0 ms latency, 0 server load, 0 bandwidth cost.
- Traps: stale content when you deploy; use cache-busting URL hashes (app.a1b2c3.js).
2. CDN / edge cache — a network of geographically distributed servers holding cached content close to users.
- What to cache: anything cacheable (static + some API responses via Cache-Control: s-maxage=X).
- Wins: cross-continent latency drops from ~150 ms to ~20 ms (users hit their nearest edge).
- Traps: invalidation is slow (purge API takes seconds), you don't see cache misses in your metrics unless you look for them.
- Real cost saver: Netflix serves 95%+ of video bytes from CDN edges (Open Connect), not origin.
3. Reverse proxy cache — same-DC HTTP cache in front of your app (Varnish, NGINX, Envoy). - What to cache: rendered HTML pages, API responses that vary by URL/headers. - Wins: takes CPU-heavy render work off your app servers. - Traps: cache-key design is tricky (should you vary by cookie? user? locale?).
4. Application-level cache (in-process) — cache inside the app process (Java Caffeine, Node lru-cache, Python functools.lru_cache).
- What to cache: hot lookups that don't need to be shared across servers (config, feature flags, hot user profiles).
- Wins: microsecond latency, no network hop.
- Traps: inconsistent across servers (each app has its own copy) — cache invalidation across N app servers is hard.
5. Distributed cache — Redis, Memcached, or a managed service (ElastiCache, DynamoDB DAX). - What to cache: anything shared across app servers — session state, hot DB queries, rate-limit counters, computed values. - Wins: microservices architecture's default cache layer. One source of truth for cached data across the fleet. - Traps: network hop (~1 ms in-DC), you own capacity + eviction + fault tolerance.
6. Database's own cache — the DB itself has a buffer pool (Postgres shared_buffers, MySQL InnoDB buffer pool) and query plan cache. - What to cache: the DB does this for you — recently-read pages, prepared statement plans. - Wins: essentially free; well-tuned DBs serve 95%+ of reads from RAM. - Traps: if your working set > buffer pool → thrashing, disk IO explodes. Right-size your DB instance.
Design principle: every layer you skip is 10× the latency of the layer above. Cache as high in the stack as possible — a hit at the browser is free; a hit at Redis costs 1 ms + a network round-trip; a hit at Postgres buffer pool costs 100–500 µs + a network round-trip + query parsing.
3. Cache write strategies — how does the cache and DB stay in sync?
Four canonical patterns. Know all four and their trade-offs.
3.1 Cache-aside (a.k.a. lazy loading) — most common
The app manages the cache explicitly.
Read path:
1. Look in cache. Hit? Return.
2. Miss? Read from DB.
3. Populate cache.
4. Return.
Write path:
1. Write to DB.
2. Invalidate or update cache.
- Pros: simple, works with any cache, only cached data is what's been accessed (no cold cache pollution).
- Cons: every miss = one extra RTT; cache and DB can diverge if writes fail partway.
- Use for: most read-heavy workloads. Redis + Postgres + your app doing it manually.
3.2 Read-through
The cache itself knows how to load from the DB on miss (encapsulated inside the cache client or a library).
Read path:
1. App asks cache.
2. Cache hit? Return.
3. Miss? Cache internally reads from DB and populates itself.
4. Return.
- Pros: app code is simpler (just talks to cache).
- Cons: cache library must know how to talk to your DB; less flexibility.
- Use for: systems with a cache-abstraction library (e.g. AWS DAX in front of DynamoDB).
3.3 Write-through
Writes go synchronously through the cache to the DB.
Write path:
1. App writes to cache.
2. Cache writes to DB (blocking).
3. Ack back to app.
- Pros: cache is always consistent with DB.
- Cons: writes are slower (must wait for DB); cold cache pollution (every write goes in even if you never read it).
- Use for: write-heavy workloads where cache-consistency matters.
3.4 Write-back (write-behind)
Writes go to cache; cache flushes to DB asynchronously.
Write path:
1. App writes to cache. Ack immediately.
2. Cache flushes to DB later (batched).
- Pros: blazing-fast writes; batching amortizes DB overhead.
- Cons: you can lose writes on cache crash (data was acknowledged but never persisted). Requires the cache to be durable enough to survive.
- Use for: high-write workloads where some data loss is acceptable (analytics counters, metrics) or where the cache itself is durable (Kafka + downstream flusher).
3.5 Comparison at a glance
| Strategy | Consistency | Read latency | Write latency | Risk of data loss |
|---|---|---|---|---|
| Cache-aside | Eventual | Fast on hit | Fast | Low (DB is source of truth) |
| Read-through | Eventual | Fast on hit | Fast | Low |
| Write-through | Strong | Fast on hit | Slow | None |
| Write-back | Eventual | Fast on hit | Very fast | HIGH |
4. Cache eviction policies
Cache is finite. When it's full, something gets kicked out. Which one?
4.1 LRU (Least Recently Used) — default and safe
Evict whatever was accessed the longest time ago.
- Great for: temporal locality (recent things get accessed again). Almost all workloads have this.
- Bad for: scan-heavy workloads that touch every item once (destroys the cache).
- Standard in Redis with
maxmemory-policy allkeys-lru.
4.2 LFU (Least Frequently Used)
Evict whatever has the lowest access count.
- Great for: long-term popularity (a Wikipedia page that's been accessed 1M times shouldn't get evicted just because a burst of new pages came in).
- Bad for: trends that change (yesterday's hot page stays cached even after everyone's moved on).
- Fixed by: LFU with aging or LFU-DA (dynamic aging) or W-TinyLFU (used by Caffeine).
4.3 TTL-based (Time To Live)
Every entry has an expiration; when time's up, it's evicted regardless of access.
- Great for: data with natural freshness bounds (session expires in 30 min; stock prices expire in 5 s).
- Combine with LRU/LFU: most caches use TTL plus LRU — evict expired first, then LRU-tail if still full.
4.4 FIFO / random
Rarely optimal but sometimes fine (random is surprisingly OK for high-throughput caches — no bookkeeping, and cache lines all become "one of many").
4.5 Practical decision
- Start with LRU + TTL. Covers 90% of cases.
- Move to LFU/W-TinyLFU for content-recommendation-style workloads with long-tail popularity.
- Switch to write-through with no eviction for small, hot lookup tables (feature flags).
5. Cache consistency — "the second hard thing"
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Why cache invalidation is hard: 1. Stale data — an update happens; cache still serves old value. How long before all clients see the new one? 2. Race conditions — concurrent write + read can leave cache and DB inconsistent forever (writer sets cache to new value; reader misses, reads old value from DB replica lag, overwrites cache with old value). 3. Distributed invalidation — with N app servers and M cache nodes, telling everyone is a coordination problem.
Common patterns to reason about
TTL-based freshness. Set a TTL that matches your consistency budget. Users can tolerate 30-second-stale product prices? TTL = 30 s. Simple, cheap, "eventually consistent."
Cache invalidation on write (cache-aside):
1. Write DB.
2. DELETE from cache.
3. Next read: cache miss → repopulate.
Simpler than trying to update the cache — deletion is idempotent and race-safe.
Versioned keys / cache stampede avoidance:
- Use versioned keys: user:42:v7 — bump the version on any change; old entries expire on their own.
- Use request coalescing (a.k.a. single-flight): when 1000 threads all miss the same key, only ONE of them queries the DB; the rest wait and use the result. Prevents the thundering herd.
Read-your-own-writes. After a user updates their profile, they immediately reload — and see the old value. Confusing. Fixes: - Write to cache and DB (double-write). - For that user's next N reads, force a DB read (session-scoped consistency). - Route the user's traffic to the primary (session pinning).
Cache stampede / thundering herd
When a popular key expires and 10,000 concurrent readers all miss simultaneously → 10,000 DB queries → DB melts.
Mitigations:
- Request coalescing / single-flight. (Go's singleflight package, Java's AsyncLoadingCache.)
- Probabilistic early expiration. Some readers refresh the cache before it expires (e.g. XFetch algorithm).
- Locking on cache miss. First reader takes a lock, populates cache, releases; others wait.
- Serve stale + refresh in background. Return the stale value immediately, queue a background refresh. (stale-while-revalidate header.)
6. Hot keys — the shard-buster
A "hot key" is a cache key that receives disproportionate traffic (e.g. a celebrity's profile on a social app).
- One Redis shard handles that key → that shard saturates while others idle.
- Classic P99 spike source: request queueing on a single shard.
Mitigations: - Replication of hot keys across multiple shards (client picks randomly). - Client-side caching for the hottest keys (a level above distributed cache). - Sharding by (key, sub-key) to split the load. - Detect them: log which keys make up top 1% of traffic.
7. Interview angle (what FAANG asks)
Common flavors:
- "How would you speed up this slow endpoint?" → Walk the 6-layer cake. Ask about read/write ratio, staleness tolerance, key cardinality.
- "Cache-aside vs write-through vs write-back — which and why?" → Section 3.5 matrix. Match to consistency and read/write pattern.
- "What's the effective latency with a 95% hit rate?" → Use the equation. Show your work.
- "How do you invalidate the cache when the DB changes?" → TTL + delete-on-write for most cases. For strict consistency, DB CDC (change-data-capture) via Kafka → cache invalidation service.
- "How would you handle a cache stampede?" → Single-flight, probabilistic early expiration, stale-while-revalidate.
- "You've got hot keys — what do you do?" → Section 6.
Trap they set: they'll say "just add a cache and we're done." A weak candidate says "yes." A strong candidate asks: - What's the read/write ratio? (Cache wins on read-heavy workloads.) - What's the staleness tolerance? (Drives TTL, invalidation strategy.) - What's the key cardinality? (Too many unique keys = low hit rate.) - What's the hit rate we can expect? (Which drives whether cache is worth its cost.)
8. Quick reference card
- Cache wins: latency ↓, throughput ↑, cost ↓. Hit rate has non-linear effect on effective latency.
- 6-layer cake: Browser → CDN → Reverse proxy → App-local → Distributed (Redis) → DB buffer pool.
- Write strategies: Cache-aside (default), Read-through, Write-through (consistent, slow), Write-back (fast, risky).
- Eviction: LRU + TTL (default); LFU/W-TinyLFU for long-tail popularity.
- Consistency: TTL for eventual; delete-on-write for cache-aside; versioned keys for correctness under races.
- Stampede prevention: single-flight, probabilistic early expiration, stale-while-revalidate.
- Hot keys: replicate across shards, client-side cache, sub-key sharding.
- Cache invalidation is one of the two truly hard things — always be explicit about TTLs and staleness bounds.
Ready for the Day 5 quiz? Open it in the sidebar. Includes a spaced-repetition callback on tail-latency amplification (Day 2 Q3 — the "recognize when to reach for the formula" skill). Answer in chat when done.