Overview
Cache invalidation is keeping cached data consistent with its source of truth. It is famously one of the two hard problems in computer science because caches are copies: every write creates a window in which some readers see old data. The design question is how long that window may be and how you shrink it.
The main strategies are expiry (TTL), explicit invalidation on write (delete the key), write-through (update cache and database together), and event-driven invalidation (change data capture streams publish updates). Production systems combine a TTL safety net with explicit or event-driven invalidation.
When prices change, the kitchen knows immediately, but printed menus on tables are copies. You can reprint menus every morning (TTL), replace menus as soon as a price changes (explicit invalidation), or put a screen on each table that always shows the kitchen's current price (no cache).
When to use it
- Any cache in front of mutable data.
- Designing how writes propagate to caches, CDNs, and search indexes.
- Debugging users seeing stale data.
- Choosing TTLs per data type.
Where it shows up in interviews
Recognize it when: cache product, profile, or config data that changes occasionally.
- Design a product catalog
- Design a user profile service
Recognize it when: browser, CDN, app cache, and search index must all update.
- Design a news site with breaking updates
- Design Twitter's cached timelines
Where it is used in real software
Facebook invalidates memcache keys using the MySQL replication stream (McSqueal) and leases to prevent stale sets and thundering herds.
Streams database changes to Kafka, where consumers invalidate caches and update search indexes.
Fastly and Cloudflare tag cached responses so one purge call invalidates every page containing a changed product.
Key terms
- TTL
- Entries expire after a time; bounds staleness.
- Delete on write
- After updating the database, delete the cache key so the next read reloads it.
- Write-through
- Write to cache and database in the same operation.
- CDC
- Change data capture: reading the database log to publish every change.
- Stale-while-revalidate
- Serve the old value while refreshing it in the background.
Safe cache-aside invalidation
- 1Write to the database first
The database is the source of truth.
- 2Delete the cache key
Deleting is safer than setting a new value, which can race with other writers.
- 3Readers repopulate on miss
Next read loads fresh data and caches it with a TTL.
- 4Keep a TTL anyway
If a delete is lost, the entry still expires.
- 5For strict freshness, use CDC
Invalidate from the database log so no write path can forget to invalidate.
The classic race condition
Reader A and Writer B, cache-aside with 'set on read'
| Step | Reader A | Writer B | DB value | Cache value |
|---|---|---|---|---|
| 1 | cache miss, reads DB: v1 | - | v1 | - |
| 2 | (paused) | writes v2 to DB | v2 | - |
| 3 | (paused) | deletes cache key | v2 | - |
| 4 | sets cache = v1 | - | v2 | v1 (stale) |
NOWStep: 1 | Reader A: cache miss, reads DB: v1 | Writer B: - | DB value: v1 | Cache value: -
The stale v1 stays until TTL expiry. Fixes: short TTLs, delayed double delete (delete again after a short delay), leases or version checks on set, or CDC-based invalidation.
Implementation
async function updateProduct(id: string, changes: Partial<Product>) { await db.products.update(id, changes); // 1. source of truth await redis.del(`product:${id}`); // 2. invalidate setTimeout(() => redis.del(`product:${id}`), 500); // 3. delayed double delete closes the race window await cdn.purgeByTag(`product-${id}`); // 4. edge caches} // Versioned set: only write the cache if our copy is not older than what is cachedasync function setIfNewer(key: string, value: Product) { const script = ` local current = redis.call('HGET', KEYS[1], 'version') if (not current) or tonumber(ARGV[1]) > tonumber(current) then redis.call('HSET', KEYS[1], 'version', ARGV[1], 'data', ARGV[2]) redis.call('EXPIRE', KEYS[1], 300) return 1 end return 0`; return redis.eval(script, { keys: [key], arguments: [String(value.version), JSON.stringify(value)] });}Complexity and performance
Simple upper bound.
Plus rare race windows.
Log reading delay.
Trade-offs
TTL-only is trivial but stale for up to TTL; CDC invalidation is fresh and reliable but adds Kafka and consumers to operate.
Deleting causes one extra miss but avoids races between concurrent writers setting different values.
Variants and related techniques
Write to the cache and flush to the database later; fast but risks data loss.
Include a version in the key (product:42:v7) so updates simply use a new key.
Invalidate groups of entries by tag at the CDN.
Common mistakes
- Updating the cache before the database.
Fix: If the DB write fails, the cache holds data that never existed. Write the DB first.
- No TTL on cache entries.
Fix: A missed invalidation would otherwise be permanent.
- Forgetting derived caches.
Fix: A product change may need invalidation of listing pages, search results, and recommendations.
Interview questions
Why delete the key instead of updating it on writes?
Two concurrent writers can update the cache in the opposite order from the database, leaving an old value cached. Deleting forces the next reader to load the current database value.
How would you keep caches consistent across many services?
Publish change events from the source of truth (CDC via Debezium or an outbox table) and let each cache owner invalidate on those events, with TTLs as a backstop.
Practice problems
| Problem | Difficulty | What it trains |
|---|---|---|
| Choose TTLs for 5 data types in an e-commerce site | Easy | Staleness tolerance. |
| Fix the stale-cache race in cache-aside | Medium | Leases and versioning. |
| Design CDC-driven invalidation for caches and search | Hard | Event pipeline. |