Courses › System Design › Core Concepts
Caching
Where to cache, cache-aside vs write-through, eviction, invalidation and stampedes.
Caching keeps frequently used data somewhere faster than its source of truth. It's the most common answer to "how do we make reads faster?" — and the most common source of subtle bugs. Interviewers want to see that you know both.
Why cache?
Memory is roughly 100× faster than an SSD-backed database query, and a cache absorbs load that would otherwise hit your database. A cache makes sense when:
- Reads significantly outnumber writes
- The same data is read repeatedly (a skewed, "hot" working set)
- Slightly stale data is acceptable — or you have a clear invalidation strategy
Where to cache
Client -> CDN -> [App Server (in-process cache)] -> (Redis) -> (Database)Caching strategies
Cache-aside (lazy loading) — the default. The app checks the cache; on a miss, it reads the DB and populates the cache.
def get_product(product_id):
key = f"product:{product_id}"
cached = redis.get(key)
if cached is not None:
return cached # hit
product = db.fetch_one("SELECT * FROM products WHERE id = %s", product_id) # miss
redis.set(key, product, ex=300) # keep it for 5 minutes
return productWrite-through — writes go to the cache and the DB together. The cache is always fresh, but every write pays the cost, and you cache data that may never be read.
Write-behind (write-back) — writes go to the cache, which flushes to the DB asynchronously. Very fast writes, but you can lose data if the cache fails before flushing.
Read-through — like cache-aside, but the cache library loads from the DB itself.
Eviction policies
Caches are finite. When full, something gets evicted:
- LRU (least recently used) — the sensible default
- LFU (least frequently used) — better when popularity is stable
- TTL — entries expire after a fixed time, which bounds staleness
Invalidation — the hard part
When the source data changes, the cache must change too. Options, from simplest to strongest:
- TTL only — accept up to N seconds of staleness. Often good enough.
- Delete on write — after updating the DB, delete the cache key so the next read reloads it.
- Event-driven invalidation — publish change events (e.g. via change data capture from the write-ahead log) that invalidate or update caches.
[!WARNING] The delete-then-race problem If a reader misses the cache, reads the old value from the DB, and writes it to the cache *after* a writer deletes the key, the stale value lives on until its TTL. Short TTLs bound the damage; versioned values prevent it.
Failure modes to mention
- Cache stampede (thundering herd): a hot key expires and thousands of requests hit the DB at once. Fix with request coalescing (only one request recomputes), a lock, or early probabilistic refresh.
- Hot keys: one key gets so much traffic that its cache node is overloaded. Replicate the key across nodes or add a small in-process cache in front.
- Cold start: after a deploy or cache failure, everything misses. Warm the cache, or make sure the DB can survive the burst.
- Cache down: decide whether to fail open (go to the DB, possibly with rate limiting) or degrade the feature.
For sharding a cache across many nodes, hot-key replication and consistency at scale, see Caching at Scale.
[!INTERVIEW] Always say what you're caching and why "I'll cache the event details and seat map in Redis with a 30-second TTL — they're read millions of times during a sale and a few seconds of staleness is fine, since the booking path checks the database anyway."