Skip to article
ALGORITHMICSSystem Design
System Design7 min read

Caching Strategies

Where the copy lives, who writes it, and the two hard problems underneath.


A cache is a bet: this will be asked for again, and recomputing it costs more than storing it.

Everything else — eviction policy, write strategy, invalidation — is detail around that bet. But the detail is where systems fall over.

What the hit rate actually buys

average latency
13 ms
load reaching origin
10%
vs no cache
9.3×

Going from 90% to 99% hits looks like a small change and nearly halves the average (13 ms → 2 ms) — because the misses dominate the mean, not the hits. It also cuts origin load tenfold, which is usually the real reason the cache exists.

Read strategies

Cache-aside (lazy loading). The application checks the cache, and on a miss loads from the database and populates it.

async function getUser(id: string) {
const cached = await cache.get(id);
if (cached) return cached;
const user = await db.users.find(id);
await cache.set(id, user, {ttl: 300});
return user;
}

Most common, and the default. Only requested data is cached, and the cache going down degrades performance rather than availability. The costs: every miss pays three round trips, and the cache can go stale relative to writes it never saw.

Read-through. The cache itself loads on a miss. Same behaviour, but the loading logic lives in one place instead of at every call site.

Write strategies, and the one that surprises people

Write-through — write to cache and database together. The cache is never stale; every write pays both latencies.

Write-behind — write to cache, flush to the database asynchronously. Very fast writes, and you will lose data if the cache dies before the flush.

Write-around — write only to the database and invalidate the cache entry.

Eviction

LRU — evict the least recently used. The default, and it is right most of the time.

LFU — evict the least frequently used. Better for stable popularity, worse at adapting.

TTL — expire on a clock. Simplest, and the only one that bounds staleness.

W-TinyLFU — what Caffeine and modern caches actually use: an LRU window in front of a frequency filter. It beats both, and it exists because plain LRU is destroyed by a scan.

The three failure modes

Stampede (or dog-piling). A popular key expires, a thousand requests miss simultaneously, and all thousand hit the database. Fix with a lock so one request recomputes while the others wait, or by refreshing before expiry.

Penetration. Requests for keys that do not exist are never cached, so every one reaches the database. Cache the negative result, or put a Bloom filter in front.

Avalanche. Everything populated at the same time expires at the same time. Add jitter — ttl * (0.8 + Math.random() * 0.4) — so expiry spreads out.

Where the copy lives

Each layer is faster and less consistent than the one below it:

LayerLatencyInvalidation
CPU cache~1 nshardware
In-process~100 nsnone — every instance differs
Redis / Memcached~1 msone place, shared
CDN~10 mspurge API, minutes
Browser0you cannot. Version the URL.

That last row is worth taking seriously. Once a resource is in a user’s browser with a long TTL, it is out of your control until it expires. This is why every build tool emits app.4f3a2b.js — the only reliable invalidation is a different name.