Engineering Notes
Home/Databases/Caching/Cache Invalidation
Databases · Caching

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.

🗄️ Databases● Intermediate⏱ 6 min read▶ 2 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 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

"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:

StrategyHowStaleness boundCost
Expiration (TTL)every entry dies after N seconds≤ TTLsimplest; stale for up to TTL (07-ttl/)
Explicit invalidationon write, delete/update the cached key(s)~0 (if it works)must know every key affected by a write
Versioned keysinclude a version in the key; bump the version on write0 for new readersold 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:

ts
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:

text
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 TTL

With 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:

text
reader: cache miss → SELECT price (gets OLD 10)
writer: UPDATE price=12 → DEL cache
reader: SET cache price=10          ← stale value written after the delete

Mitigations, in increasing strength:

  1. Always set a TTL — bounds how long any such race can last.
  2. Delayed double delete — delete again ~a second after the write, catching late refills.
  3. Version/compare-and-set on fill — readers only write the cache if the version they read is still current (e.g. SET key value only if key:version matches, via a Lua script).
  4. 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 = 18 and build keys as category: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:

text
UPDATE products ... COMMIT → WAL → Debezium → Kafka topic "db.products" → invalidator → DEL product:42

Because 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.

A reader misses and reads the old value, a writer updates the database and deletes the key, then the slow reader writes the stale value back into the cache; a lease or version check rejects that late fill
A reader misses and reads the old value, a writer updates the database and deletes the key, then the slow reader writes the stale value back into the cache; a lease or version check rejects that late fill

⚠️ Common Pitfalls

  1. Updating the cache instead of deleting — concurrent writers leave the older value cached.
  2. Deleting before writing the DB — a reader refills the old value immediately.
  3. No TTL "because we invalidate explicitly" — any missed invalidation becomes permanent staleness.
  4. Forgetting derived keys — lists, search results, aggregates and translations of the changed entity.
  5. Invalidating only the shared cache while instances keep L1 copies.
  6. Caching permission/authorization results without invalidating on role changes — revoked access keeps working.

📋 Quick Reference

SituationApproach
single entity changedwrite DB → DEL key (+ TTL as a safety net)
many dependent keystags (SET of keys) or versioned key prefix
writes from many sourcesCDC-driven invalidation
many app instances with L1pub/sub or client-side-caching invalidation
static assetscontent-hashed filenames, long TTL
must never be staledon'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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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).

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. Why delete the cache key instead of setting the new value on write?
Delete makes the worst case an extra miss.
2. Correct order on a write?
Deleting first lets readers refill old data.
3. Which closes the gap of writes that bypass your application code?
Every committed change emits an invalidation.
4. What always bounds staleness from missed invalidations?
Always set one.