Caching & the CDN
The cheapest request is the one you never make. Cache layers, invalidation, and why it is genuinely hard.
The fastest request is the one you never make. Caching — keeping a copy of expensive-to-produce data somewhere fast — is the highest-leverage performance technique in systems design, which is why it appears in almost every design interview. It’s also, famously, one of the two hard problems in computer science.
Why caching wins
Recall the numbers from the last lesson: memory is roughly a thousand times faster than disk, and a cross-continent round trip is ~100 ms. A cache turns a slow database query or a remote API call into a memory lookup. For read-heavy workloads — which most systems are — a well-placed cache can absorb the large majority of traffic before it ever touches your database.
Caches live at every layer
Caching isn’t one thing; it’s a strategy applied all the way down the stack:
- Browser / client — cache static assets and API responses locally.
- CDN — a Content Delivery Network stores copies at edge locations physically near users, so a request from Tokyo is served from Tokyo, not from a server in Virginia. Ideal for images, video, JS/CSS, and cacheable API responses.
- Application cache — an in-memory store like Redis or Memcached sitting between your app and the database, holding hot query results, sessions, and computed values.
- Database cache — the database’s own buffer pool keeps recent pages in memory.
The cache-aside pattern
The most common application pattern. The application manages the cache explicitly:
def get_user(user_id):
cached = cache.get(user_id)
if cached is not None:
return cached # cache hit
user = db.query(user_id) # cache miss → hit the source
cache.set(user_id, user, ttl=300) # populate for next time
return user
Two numbers define its health: the hit rate (fraction served from cache) and the TTL (how long an entry lives before it must be refetched).
Invalidation: the genuinely hard part
Reading from a cache is easy. Keeping it correct is not — that’s the “hard problem.” When the underlying data changes, stale cache entries can serve wrong answers. The main strategies:
- TTL expiry — entries simply expire after N seconds. Dead simple; the cost is a window of staleness you must decide is acceptable.
- Write-through — write to the cache and the database together, so the cache is never stale (at the cost of slower writes).
- Explicit invalidation — delete or update the cache entry when the data changes. Precise, but easy to get wrong when many things touch the same data.
The right question is never “how do I cache this?” but “how stale is this data allowed to be?” A stock price and a user’s display name have wildly different tolerances, and that tolerance dictates the strategy.
Failure modes to name in an interview
- Thundering herd / cache stampede — a popular key expires and thousands of requests hit the database simultaneously to refill it. Mitigate with locks so only one request rebuilds, or by staggering expiry.
- Cold cache — after a restart or deploy, the cache is empty and the database briefly takes full load. Warming strategies help.
Mentioning these unprompted signals that you’ve operated caches, not just read about them.
Pattern recognition
Variations
Worked problems
Caching problems are freshness problems disguised as performance problems. Always say what stale answer is acceptable.
Invalidate product detail safely
A product detail page is cached in Redis for 30 minutes. Marketing complains that price changes can take half an hour to appear. The page receives heavy traffic, so removing the cache is not an option. What do you change?
Approach. Price is more freshness-sensitive than descriptions or images. Keep caching, but make writes invalidate or version the affected key instead of waiting for TTL alone.
Show solution
A better policy:
read product -> cache-aside with TTL
update price -> transaction commits in DB
-> publish ProductUpdated(product_id, version)
-> delete or replace cache key product:{id}You can also version the key:
metadata row stores cache_version = 18
cache key = product:{id}:v18
price update increments version to 19
old key naturally expires; readers use v19 immediatelyTrade-offs:
| Strategy | Pro | Con |
|---|---|---|
| Short TTL only | Simple | More database load and still stale within TTL |
| Explicit delete | Fast freshness | Missed invalidation event leaves stale data |
| Versioned key | Avoids stale overwrite races | More keys until old versions expire |
| Write-through | Fresh cache after write | Slower write path and more coupling |
For price, explicit invalidation or versioned keys are worth it. For product long descriptions, a longer TTL may be fine.
Stop a cache stampede
The homepage feed cache expires every 60 seconds. At peak, 50,000 requests/sec ask for the same key. Rebuilding the feed takes 200 ms of database work. When the key expires, the database falls over. Why, and what do you change?
Approach. The miss path is too expensive to let every request execute it. Allow one refill, serve stale data to others, and avoid synchronized expirations.
Show solution
If the key expires and every request rebuilds:
50,000 requests/sec × 0.2 sec query time = ~10,000 concurrent DB queriesThat is not a cache miss; it is a denial-of-service attack your own cache caused.
Mitigations:
- Single-flight lock — first request obtains
rebuild:homepage; others wait briefly or serve stale. - Soft TTL / hard TTL — after soft expiry, one worker refreshes in the background while users still get the old value; hard expiry is the safety limit.
- Jittered TTLs — add randomness so many keys do not expire at the same second.
- Pre-warming — refresh known hot keys before they expire.
- Request coalescing — collapse identical concurrent misses into one source call.
The response path becomes:
cache hit and fresh -> return
cache hit but soft-expired -> trigger one refresh, return stale
cache miss and lock acquired -> rebuild, set cache, return
cache miss and lock held -> wait briefly or return fallbackServing a 10-second stale homepage is usually better than taking the database down for everyone.
Choose a write policy
You are caching three things: user notification preferences, product inventory counts, and article view counters. For each, choose write-through, write-back, or write-around/cache-aside.
Approach. Match the write policy to the harm of stale or lost data. The cache is not automatically the source of truth.
Show solution
| Data | Policy | Reason |
|---|---|---|
| Notification preferences | Write-through or explicit invalidation | User expects changes to apply immediately |
| Product inventory | Source-of-truth DB transaction, cache-aside read model | Overselling is worse than a cache miss |
| Article view counters | Write-back/buffered aggregation | Slight delay or small loss may be acceptable |
For preferences, update the database and cache together, or delete the cache key after the DB commit so the next read refills. For inventory, do not trust a cache to serialize purchases; reserve inventory in the database, then update or invalidate cached availability. For view counters, increment in memory or a stream and flush periodically because exact per-view durability rarely matters.
The important sentence in an interview: “I will not put correctness-critical state only in a write-back cache unless I can tolerate losing the latest writes.”