Caching Strategies
Caching trades freshness for speed. Decide where to cache (browser, CDN, app memory, Redis), what to cache, and how to evict, then simulate LRU, LFU and FIFO on a real access pattern and watch the hit ratio change.
🎯 What you'll learn
- Why cache: latency, load, cost
- Cache layers from browser to database
- What's worth caching
- Eviction policies and hit ratio
📚 Before you start
1Why Cache at All
A cache keeps a copy of data somewhere faster or closer than its source of truth, so repeated reads skip the slow path.
| Operation | Typical latency |
|---|---|
| L1 CPU cache | ~1 ns |
in-process memory (a Map) | ~100 ns |
| Redis on the same network | ~0.2–1 ms |
| indexed PostgreSQL query | ~1–10 ms |
| cross-region HTTP call | ~100–300 ms |
Caching trades freshness and memory for latency and load reduction. Every caching decision is really three questions: where does the copy live, what gets cached, and when does it stop being valid (05-cache_invalidation/, 07-ttl/).
2Where: The Cache Layers
browser cache → CDN edge → reverse proxy → application (in-process) → shared cache (Redis) → DB buffer pool → disk
closest to the user closest to the data| Layer | Holds | Shared by | Invalidated by |
|---|---|---|---|
| Browser (HTTP cache) | responses, assets | one user | Cache-Control, ETag |
| CDN | public responses, static files | all users in a region | TTL, purge API |
In-process (LRU Map) | hot objects | one app instance | TTL, local eviction |
| Shared cache (Redis/Memcached) | objects, query results, sessions | all instances | TTL, explicit delete |
| DB buffer pool | data pages | all queries | the database itself |
The closer to the user, the bigger the win — and the harder invalidation gets (you can't delete something from a million browsers).
Two-level caching combines a tiny in-process L1 (microseconds, per instance) with a shared L2 (Redis): L1 absorbs the very hottest keys, L2 keeps instances consistent with each other. The cost: L1 copies on different instances can disagree until they expire, so keep L1 TTLs short.
3What to Cache
Good candidates share three traits — read often, change rarely, expensive to compute:
- rendered fragments, product/catalog data, configuration, feature flags
- results of expensive aggregations ("top 10 products this week")
- remote API responses (exchange rates, geolocation)
- session data and rate-limit counters (here the cache is the store — see
01-redis/)
Poor candidates: data that must be perfectly fresh (account balances at checkout), data that's unique per request (search queries with a long tail of distinct inputs — low hit ratio), and anything cheaper to compute than to fetch and deserialize.
Cache key design matters as much as what you cache:
product:{id}:v3 # versioned: bump v3 → v4 to invalidate everything after a format change
user:{userId}:feed:page:{n}
search:{sha1(normalizedQuery)} # normalize first: "Shoes " and "shoes" should share a key
tenant:{tenantId}:settings # tenant prefix: never serve one tenant's data to another4Reading Patterns, Briefly
- Cache-aside (lazy loading): the application checks the cache, on a miss reads the DB and populates the cache. The default choice.
- Read-through: the cache library itself loads from the DB on a miss; the app only talks to the cache.
- Refresh-ahead: popular keys are refreshed before they expire, so users never see a miss.
Write patterns (write-through, write-behind, write-around) and their consistency trade-offs are the subject of 06-cache_aside_write_through_write_behind/.
5Eviction Policies
A cache is bounded. When it's full, something must go:
| Policy | Evicts | Good when | Weakness |
|---|---|---|---|
| LRU (least recently used) | the key untouched the longest | recency predicts reuse (most workloads) | a one-off scan of many keys flushes the hot set |
| LFU (least frequently used) | the key with the fewest hits | stable popularity (top products) | slow to adapt when popularity shifts; needs decay |
| FIFO | the oldest inserted | simple, predictable | ignores usage entirely |
| TTL-based | expired keys first | data with natural freshness limits | not a capacity policy on its own |
| Random | a random key | very cheap, surprisingly OK | unpredictable |
Redis exposes these via maxmemory-policy (allkeys-lru, allkeys-lfu, volatile-ttl, ...); Memcached uses LRU per slab class (02-memcached/).
6Measuring: Hit Ratio Is the Number
hit ratio = hits / (hits + misses)
effective latency ≈ hit_ratio × cache_latency + (1 − hit_ratio) × (cache_latency + db_latency)With a 1 ms cache and a 20 ms DB: 50% hits → ~11 ms average; 90% → ~3 ms; 99% → ~1.2 ms. The last few percent matter enormously — and so does what the misses cost the database: at 99% hits, the DB sees 1% of traffic; drop to 90% and DB load goes up 10×. That's why a cache restart (cold cache) can take a database down: suddenly 100% of traffic is misses.
Watch: hit ratio per key prefix, eviction rate (evictions with a low hit ratio = cache too small), memory, and p99 latency of the cache itself.
Under the HoodHow an O(1) LRU Works▾
An LRU cache needs two operations to be O(1): look up by key and find/move the least-recently-used entry. The classic structure combines a hash map with a doubly linked list:
map: key → node
list: [MRU] ⇄ node ⇄ node ⇄ node ⇄ [LRU]
get(k): node = map[k]; unlink(node); pushFront(node) O(1)
set(k): if full: evict list.tail, delete map[tail.key] O(1)
insert node at front, map[k] = nodeIn JavaScript, a Map already iterates in insertion order, so "delete then re-set" moves a key to the end, and map.keys().next().value is the oldest — a compact LRU with no explicit list.
Redis does not keep a global linked list (it would cost memory per key and lock contention). Instead each object stores a 24-bit LRU clock, and on eviction Redis samples maxmemory-samples random keys (default 5) and evicts the best candidate among them, keeping a small pool of good candidates between rounds. Approximate LRU with 10 samples is nearly indistinguishable from true LRU in practice, at a fraction of the cost. Redis's LFU mode reuses the same 24 bits as an 8-bit logarithmic counter plus a decay timestamp, so frequency estimates fade as keys cool down.
⚠️ Common Pitfalls
- Caching without measuring hit ratio — a 20% hit-ratio cache adds latency and complexity for little benefit.
- Unbounded in-process caches — a
Mapthat only grows is a memory leak with extra steps. - Keys that ignore tenant/user/locale — serving one user's data to another is a security incident, not a cache bug.
- Caching errors or empty results forever — a transient DB failure becomes a long outage. Cache negative results only with short TTLs.
- Cold-start stampede — deploying with an empty cache sends 100% of traffic to the DB; warm critical keys or ramp traffic.
- Large values — multi-MB values monopolize network and (in Redis) the single thread; cache smaller pieces.
📋 Quick Reference
| Decision | Default |
|---|---|
| read pattern | cache-aside |
| shared store | Redis |
| eviction | LRU (allkeys-lru) unless popularity is very stable (LFU) |
| key format | entity:{id}:v{n}, tenant/locale included when relevant |
| freshness | TTL on every key + explicit invalidation on writes |
| health metric | hit ratio + eviction rate |
🧠 Key takeaways
- Cache to cut latency, load and cost; every cache is a staleness trade-off.
- Layers: browser → CDN → in-process L1 → shared Redis → database; closer is faster but harder to invalidate.
- Cache hot, expensive, read-mostly data; split shared and per-user data.
- Pick eviction by access pattern (LRU default, LFU for stable hot sets) and measure the hit ratio.
🎤 Interview Q&A
Q1What is cache-aside and when do you use it?core▾
The application checks the cache; on a miss it reads from the database and populates the cache. Writes go to the database and invalidate (delete) the cache entry. It's the most common pattern: simple, resilient (cache failure → slower, not broken), caches only what's read.
- Downside? Cold misses and a window of staleness.
Q2What are read-through, write-through and write-behind?core▾
Read-through: the cache loads missing data itself. Write-through: writes go to cache and DB synchronously, keeping the cache fresh at the cost of write latency. Write-behind: writes go to the cache and are flushed to the DB asynchronously: fast, but risk of data loss.
- When is write-behind acceptable? For data like counters or analytics where small loss is OK.
Q3What should you cache?core▾
Data that's read often, expensive to compute or fetch, and tolerant of some staleness: product details, user profiles, configuration, rendered fragments, aggregated results. Avoid caching highly volatile data or data that must be strongly consistent.
- How do you measure effectiveness? Hit ratio, latency, and database load reduction.
Q4What's the cache stampede problem and how do you prevent it?senior▾
When a hot key expires, many requests miss at once and overload the database. Prevent with request coalescing (single flight), a lock so one request refreshes, serving stale while refreshing, early probabilistic expiration, and TTL jitter.
- What's probabilistic early expiration? Requests randomly refresh a key slightly before it expires, more likely as expiry nears.
Q5Where can caching happen in a system?core▾
Browser, CDN/edge, API gateway, application in-process cache, distributed cache (Redis), and database buffer pool. Multi-level caching (local + Redis) cuts latency further but complicates invalidation.
- Risk of in-process caches? Each instance can hold different versions of the data.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.