Tier 0 · Foundations

Caching

Where to put it, how to invalidate it, and the four ways it takes you down

⏱ 20 min foundationsperformancecaching 📕 Ch 1 — Scale From Zero To Millions

Where caches live

Every layer between user and data can hold one, and each has a different invalidation story:

Layer Holds Invalidation
Browser Static assets, API responses Cache-Control, versioned URLs — you cannot reach into it
CDN / edge Static and cacheable dynamic Purge API, TTL, versioned URLs
Reverse proxy Rendered pages, API responses Local purge, TTL
Application memory Hot objects, config Process-local — inconsistent across instances
Distributed cache Shared objects, sessions Explicit delete, TTL
Database buffer pool Pages Automatic, not your concern

In-process caches are the fastest possible (no network hop) and the hardest to invalidate, because every instance has its own copy and they drift. They're excellent for data that is small, hot, and tolerant of staleness — feature flags, config, reference data — with a short TTL to bound the drift.

The patterns

Cache-aside (lazy loading). The application checks the cache, and on a miss reads the database and populates it. The default, and the one you should assume unless told otherwise. Only requested data is cached; a cache failure degrades to slow rather than broken. The downside is that every miss pays both lookups, and the first request after a write is stale unless you invalidate on write.

Read-through. Same shape, but the cache library does the loading. Cleaner application code, and it means the cache is on the critical path — if it's down, everything is down.

Write-through. Write to cache and database together. The cache is never stale, but every write pays both costs, and you cache data that may never be read.

Write-behind (write-back). Write to cache, flush to the database asynchronously. Fastest writes by a distance, and you will lose data if the cache dies before flushing. Use only where loss is acceptable — view counters, analytics — or with a durable buffer in front.

Refresh-ahead. Proactively refresh entries nearing expiry, so users never hit a cold one. Effective for a small, predictable hot set; wasteful otherwise.

Hit rate is logarithmic in cache size

Real traffic is skewed — a small fraction of keys serves most requests. That's why caches work at all, and it has a consequence people find surprising: most of the benefit arrives in the first few percent of capacity.

Loading simulator…
Hit rate against cache size, over Zipf-distributed traffic. Three eviction policies, same trace.

Things to notice:

The curve is steep then flat. At moderate skew, caching just 5% of the keyspace already serves 40–50% of requests depending on policy. Going from there to 40% — eight times the memory — only takes you to about 80%. Each additional nine of hit rate costs exponentially more RAM. This is the argument for sizing a cache from a measured hit-rate curve rather than from "how much can we afford".

Skew decides everything. Drag the Zipf slider. At high skew a tiny cache is spectacular; at low skew even a huge cache is mediocre. If your access pattern is close to uniform, caching is the wrong tool and no amount of memory will fix it.

LFU beats LRU here, but not always. LFU wins on a stable popularity distribution because it keeps the genuinely hot keys. It loses badly when popularity shifts, because an old-but-formerly-popular key has a high count and refuses to leave. Real systems use hybrids — segmented LRU, or W-TinyLFU (what Caffeine uses) — which approximate frequency while letting stale entries age out.

Invalidation

The hard part, and the reason for the joke.

TTL is the workhorse: bound staleness, let entries expire. Simple, self-healing, and requires no coordination. Choose the TTL from how stale the data may be, not from how often it changes.

Explicit invalidation on write gives freshness at the cost of coupling — every writer must know every cache key derived from that data, and one forgotten path means permanent staleness.

Versioned keys sidestep invalidation: include a version or content hash in the key (user:123:v7). Writers bump the version; old entries are never read again and expire on their own. This is what asset fingerprinting does, and it's the most reliable option when you can afford the key churn.

The four failure modes

Thundering herd (stampede). A hot key expires; a thousand concurrent requests all miss and all hit the database simultaneously. Fixes: single-flight — the first miss takes a lock and the rest wait for its result; probabilistic early expiry — each reader independently decides to refresh slightly before the TTL, so one lucky request refreshes while others still get the cached value; or stale-while-revalidate — serve the expired value and refresh in the background.

Cache avalanche. Many keys expire at the same moment — typically because they were all populated together at startup with an identical TTL. Fix: jitter the TTL (ttl ± 10%). One line of code, entire class of outage removed.

Cache penetration. Requests for keys that don't exist find nothing in the cache and go to the database every time — a trivially easy accidental (or deliberate) attack. Fixes: cache the negative result with a short TTL, or a Bloom filter over existing keys.

Cold start. The hook at the top. After a restart or a scale-out, hit rate is zero and the database sees full traffic. Fixes: warm the cache before taking traffic, restart nodes gradually rather than all at once, and know whether your database can survive 100% miss rate — if it can't, the cache is a single point of failure and should be treated as one.

What not to cache

What to take away

Check yourself

  1. A cache running at 95% hit rate is restarted. What does the database see?

  2. A thousand requests arrive for the same key at the instant its TTL expires. All of them miss and hit the database. What is this, and what fixes it?

  3. Your traffic has a nearly uniform access distribution across a huge keyspace. What does this imply for caching?

  4. Why is deleting a cache entry on write generally safer than updating it with the new value?