Module 06 · Caching · Lesson 4 of 8
Cache invalidation
Stale data is the price of caching. TTLs, explicit deletes, versioned keys and event-driven purges each bound staleness differently, and the races between reads and writes are where real bugs live.
Why it matters
A cache that is never updated is a bug generator. A user changes their email and the old one keeps appearing. A price drops and the cached page still shows the old number for an hour. Invalidation is how the cache learns that the world changed, and it is the part of caching most teams get wrong, because the hard cases are races that only show up under load.
The idea
There are four ways to stop serving a stale value, and most systems use two or three together.
Expire it (TTL). Every key carries a lifetime. Simple, requires no coordination with writers, and bounds staleness to the TTL. The cost is that the bound is the whole TTL: a five-minute TTL means up to five minutes of wrong answers after every write.
Delete it on write. When the application writes to the database, it also deletes (or updates) the cache key. Staleness drops to the gap between the two operations. Requires every write path to know every key it affects, which is why it drifts out of sync as a codebase grows.
Change the key. Instead of invalidating user:7, read user:7:v12 where the version is stored with the record or derived from an updated-at timestamp. A write bumps the version; old entries become unreachable and expire on their own. No deletes, no races, at the cost of one extra lookup to learn the current version.
React to the change log. Capture database changes (CDC) and publish invalidations from the stream. Writers do not need to know about the cache at all, and every path that changes the data, including migrations and admin tools, triggers the purge. Adds a pipeline and a short propagation delay.
The race nobody draws
Delete-on-write has a hole. Reader A misses and reads the old row from the database. Writer B updates the row and deletes the cache key. Reader A, slow, now writes its old value into the cache. The cache holds stale data until the TTL, even though every step was "correct".
Three mitigations show up in practice:
- Delete after the write, and again after a delay ("double delete"): the second delete, a few hundred milliseconds later, clears any value a slow reader filled in between.
- Leases: on a miss the cache hands the reader a token; the reader's fill is accepted only if no invalidation happened since the token was issued. Facebook's Memcached paper describes this; it also collapses concurrent misses.
- Short TTL as a backstop: whatever else you do, keep a TTL so the worst case is bounded. Correctness through expiry is dull and reliable.
| TTL only | Delete on write | Versioned keys | CDC-driven purge | |
|---|---|---|---|---|
| Staleness bound | Full TTL | Milliseconds, except the race | Milliseconds | Pipeline lag, typically under a second |
| Writer must know cache keys | No | Yes, every write path | Only bumps a version | No |
| Race-prone | No | Yes | No | No |
| Extra cost | None | One delete per write | One version lookup per read | A CDC pipeline |
| Catches out-of-band writes | Eventually | No | Only if they bump the version | Yes |