Cache Invalidation
Copying data into a cache creates two truths. Learn why you delete instead of update, watch the reader/writer race that still leaves stale data, and fix it with delayed double delete, versions or change-data-capture.
🎯 What you'll learn
- TTL, explicit invalidation and versioned keys
- Why delete beats set on writes
- The remaining race and delayed double delete
- Knowing which keys to invalidate, CDC, and multi-instance invalidation
📚 Before you start
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
1The Problem
The moment you copy data into a cache, there are two versions of the truth. Invalidation is how you make the cached copy stop being served once the source changes. Get it wrong and users see stale prices, deleted posts, revoked permissions — or, worse, data that belongs to someone else.
There are three basic strategies, and real systems combine them:
| Strategy | How | Staleness bound | Cost |
|---|---|---|---|
| Expiration (TTL) | every entry dies after N seconds | ≤ TTL | simplest; stale for up to TTL (07-ttl/) |
| Explicit invalidation | on write, delete/update the cached key(s) | ~0 (if it works) | must know every key affected by a write |
| Versioned keys | include a version in the key; bump the version on write | 0 for new readers | old entries linger until evicted/TTL |
2Explicit Invalidation: Delete, Don't Update
On a write, the safest default is to delete the cache entry and let the next read repopulate it:
async function updateProduct(id: string, patch: Partial<Product>) {
await db.products.update(id, patch); // 1. write the source of truth
await redis.del(`product:${id}`); // 2. invalidate
}Why delete instead of SET with the new value? Because two concurrent writers can interleave so that the older value is written to the cache last:
writer A: UPDATE price=10 writer B: UPDATE price=12
writer B: SET cache price=12
writer A: SET cache price=10 ← cache now says 10, DB says 12 — stale until TTLWith delete, the worst case is an extra miss. (Order matters too: write the DB first, then delete. Deleting first lets a reader refill the cache with the old value before the DB write lands.)
3The Remaining Race — and Delayed Double Delete
Even "update DB, then delete" has a narrow race with a concurrent reader:
reader: cache miss → SELECT price (gets OLD 10)
writer: UPDATE price=12 → DEL cache
reader: SET cache price=10 ← stale value written after the deleteMitigations, in increasing strength:
- Always set a TTL — bounds how long any such race can last.
- Delayed double delete — delete again ~a second after the write, catching late refills.
- Version/compare-and-set on fill — readers only write the cache if the version they read is still current (e.g.
SET key valueonly ifkey:versionmatches, via a Lua script). - Invalidate from the database's change stream (CDC, below) — a single ordered source of invalidations.
4Knowing Which Keys to Invalidate
A product edit doesn't just affect product:42. It may affect category:7:page:1, search:shoes, homepage:featured, and a rendered fragment in three languages. Options:
- Tag-based invalidation: when caching, record which entities an entry depends on (
tag:product:42 → {category:7:page:1, homepage:featured}as a Redis SET). On write, delete every key in the tag set. CDNs support this as surrogate keys / cache tags. - Version stamps per entity group: store
ver:category:7 = 18and build keys ascategory:7:v18:page:1. Bumping the version orphans every old page at once — no need to enumerate them. - Keep derived caches short-lived: for fan-out-heavy aggregates, rely on a short TTL instead of precise invalidation.
5Event-Driven Invalidation (CDC)
Invalidating in application code has a gap: anything that writes to the DB without going through that code (a migration, an admin script, another service) leaves the cache stale. Change Data Capture reads the database's replication log (PostgreSQL WAL via Debezium, MySQL binlog) and emits an event for every committed row change. A small consumer turns those into cache deletes:
UPDATE products ... COMMIT → WAL → Debezium → Kafka topic "db.products" → invalidator → DEL product:42Because the log is ordered and contains only committed changes, this closes both the "forgot to invalidate" and most of the ordering races — at the cost of extra infrastructure and a little added lag.
6Invalidating Many Caches at Once
With several app instances, each holding an in-process L1 (04-caching_strategies/ section 2), deleting the Redis key isn't enough. Broadcast invalidations — Redis pub/sub or Redis 6+ client-side caching (server-assisted tracking: the server remembers which keys each client cached and pushes invalidation messages) — so every instance drops its local copy.
For HTTP caches you don't control, invalidation is either a purge API (CDN), or impossible (browsers) — which is why static assets use content-hashed filenames (app.3f9a1c.js): a new version is a new URL, so the old one never needs invalidating.
Under the HoodWhy "Update DB, Delete Cache" Is Still Not Linearizable▾
The database and the cache are two separate systems with no shared transaction. The DB commit and the cache delete are two independent operations separated by network hops; between them, any number of readers can observe "new DB, old cache". And because a cache fill is read DB → (time passes) → write cache, a fill that started before the commit can complete after the delete — the race in section 3. No ordering of two non-atomic steps can eliminate this; it can only make the window smaller or bound its duration (TTL).
The only ways to get strict consistency are to (a) not cache that data, (b) make the cache a write-through part of the same consistency protocol (e.g. a lease or version check on every fill), or (c) route reads that need freshness straight to the primary. Facebook's memcache paper ("Scaling Memcache at Facebook") solves the fill race with leases: on a miss, the cache hands the reader a lease token; a delete invalidates outstanding leases, so a stale fill presenting an old token is rejected.
⚠️ Common Pitfalls
- Updating the cache instead of deleting — concurrent writers leave the older value cached.
- Deleting before writing the DB — a reader refills the old value immediately.
- No TTL "because we invalidate explicitly" — any missed invalidation becomes permanent staleness.
- Forgetting derived keys — lists, search results, aggregates and translations of the changed entity.
- Invalidating only the shared cache while instances keep L1 copies.
- Caching permission/authorization results without invalidating on role changes — revoked access keeps working.
📋 Quick Reference
| Situation | Approach |
|---|---|
| single entity changed | write DB → DEL key (+ TTL as a safety net) |
| many dependent keys | tags (SET of keys) or versioned key prefix |
| writes from many sources | CDC-driven invalidation |
| many app instances with L1 | pub/sub or client-side-caching invalidation |
| static assets | content-hashed filenames, long TTL |
| must never be stale | don't cache it, or read from primary |
🧠 Key takeaways
- Strategies: TTL (bounded staleness), explicit invalidation (precise, error-prone), versioned keys.
- On writes: update the DB first, then delete the key (don't SET new values; concurrent writers reorder).
- A reader/writer race remains: use TTLs, delayed double delete, version checks, or CDC-driven invalidation.
- Track dependent keys (tags/version stamps) and broadcast invalidations to in-process caches.
🎤 Interview Q&A
Q1Why is cache invalidation hard?core▾
You must keep copies consistent with the source of truth across concurrent writes, multiple cache layers and instances, and failures between updating the DB and the cache. Races can leave stale data in the cache indefinitely.
- Classic race? A reader loads an old value, a writer updates DB and deletes the cache, then the reader writes the old value into the cache.
Q2Delete the cache entry or update it on writes?core▾
Usually delete after writing the database: the next read repopulates with fresh data. Updating the cache directly risks writing stale values when concurrent writes interleave. Combine with TTLs as a safety net.
- Delete before or after the DB write? After; deleting before lets a concurrent read repopulate stale data.
Q3What invalidation strategies exist?senior▾
TTL-based expiry (simple, bounded staleness), explicit invalidation on writes, event-driven invalidation (CDC or domain events trigger deletes), and versioned keys (include a version in the key so new data uses new keys).
- When are versioned keys great? Static assets and computed results keyed by content hash.
Q4How do you invalidate across multiple app instances with local caches?core▾
Broadcast invalidation messages (Redis pub/sub, Kafka) so each instance evicts its local copy, keep local TTLs short, or avoid local caches for data that changes often.
- What if a message is missed? Short TTLs bound how long stale data lives.
Q5What is delayed double delete?core▾
Delete the cache entry, write the database, then delete again after a short delay, to clear any stale value repopulated by a concurrent read during the write. It narrows the race window but doesn't fully eliminate it.
- A more robust alternative? Invalidate from the database's change stream (CDC).
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.