Cache Invalidation: Strategies for Keeping Cached Data Fresh
In this tutorial, you will learn about Cache Invalidation: Strategies for Keeping Cached Data Fresh. We cover key concepts, practical examples, and best practices to help you master this topic.
Cache invalidation is the process of removing or updating cached data when the underlying source data changes. It is one of the hardest problems in computer science because you must balance freshness against performance, and stale data can cause subtle bugs.
flowchart TB
Write[Data Updated] --> Strategy{Invalidation Strategy}
Strategy -->|TTL| TTL[Wait for TTL Expiry]
Strategy -->|Event-Driven| Event[Publish Invalidation Event]
Strategy -->|Write-Through| WT[Update Cache Synchronously]
Strategy -->|Write-Behind| WB[Update Cache Asynchronously]
Strategy -->|Purge| Purge[Explicit Cache Purge]
TTL --> Serve[Serve Fresh or Stale Data]
Event --> Notify[Notify Cache Nodes]
Notify --> Serve
WT --> Serve
WB --> Serve
Purge --> Serve
What You'll Learn
- TTL-based invalidation and its trade-off between staleness and simplicity
- Event-driven invalidation using pub/sub and Message Queues
- Write-through, write-behind, and write-around cache invalidation
- Cache poisoning and invalidation storms
Why It Matters
Getting cache invalidation wrong causes users to see stale data, inconsistent dashboards, or broken functionality. A systematic invalidation Strategy is essential for any system that cannot tolerate serving outdated information.
Real-World Use
An e-commerce platform invalidates cache entries when inventory changes. When a user purchases an item, the system publishes an inventory.updated event. All cache nodes subscribed to the event delete the cached product entry, ensuring the next read fetches fresh stock levels.
Invalidation Strategies
TTL-Based Invalidation
function getWithStaleTTL(key, fetchFn, opts = {}) {
const ttl = opts.ttl || 60000;
const staleTtl = opts.staleTtl || 300000;
let cached = null;
let lastFetch = 0;
return async function() {
const now = Date.now();
if (cached && (now - lastFetch) < ttl) return cached;
if (cached && (now - lastFetch) < staleTtl) {
fetchFn().then(fresh => { cached = fresh; lastFetch = now; }).catch(() => {});
return cached;
}
cached = await fetchFn();
lastFetch = now;
return cached;
};
}
Expected output:
Within ttl: returns cached data. Between ttl and staleTtl: returns stale data, refreshes in background. After staleTtl: waits for fresh data.
Event-Driven Invalidation with Pub/Sub
class CacheInvalidator {
constructor(redisClient) {
this.redis = redisClient;
this.subscriber = redisClient.duplicate();
this.patterns = new Map();
}
subscribe(pattern, handler) {
this.patterns.set(pattern, handler);
this.subscriber.pSubscribe(pattern, (message, channel) => {
handler(channel, message);
});
}
invalidate(key) {
return this.redis.publish('cache:invalidate', key);
}
async invalidatePattern(pattern) {
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(keys);
await this.redis.publish('cache:invalidate', pattern);
}
}
}
const invalidator = new CacheInvalidator(redis);
invalidator.subscribe('cache:invalidate', (channel, key) => {
localCache.delete(key);
});
Expected output:
When any node calls invalidate('user:123'), all nodes receive the event and delete the cached entry. Next read fetches fresh data.
Write-Through Cache with DB Trigger
async function updateProduct(id, data) {
const connection = await db.getConnection();
try {
await connection.beginTransaction();
const [result] = await connection.execute(
'UPDATE products SET stock = ?, price = ? WHERE id = ?',
[data.stock, data.price, id]
);
// Write-through: update cache in same transaction
const updated = await connection.execute(
'SELECT * FROM products WHERE id = ?', [id]
);
await cache.set(`product:${id}`, JSON.stringify(updated[0]), 3600);
await connection.commit();
return updated[0];
} catch (err) {
await connection.rollback();
throw err;
}
}
Expected output:
Cache is updated atomically with the database write. Subsequent reads always see the latest data. Write latency is higher due to cache update.
Common Mistakes
- Using only TTL for data that must be immediately consistent, serving stale data during the TTL window.
- Invalidating cache entries before the database write completes, causing a race where stale data is re-cached.
- Publishing invalidation events without idempotency — if two events arrive, the cache should handle redundant deletes gracefully.
- Invalidating too aggressively, causing cache stampedes when many entries are purged simultaneously.
- Not invalidating related cache entries — when a user's email changes, also invalidate any cache key that includes the email.
Practice Questions
- What is the difference between TTL-based and event-driven invalidation?
- What is a cache invalidation storm and how can you prevent it?
- How does write-through Caching ensure consistency?
- Why should invalidation events be idempotent?
- What is cache poisoning and how can stale data cause it?
Challenge
Design an invalidation system for a social media app. When a user updates their profile picture, invalidate: user profile cache, feed entries containing the old picture URL, and friend notification caches. Use event-driven invalidation with a message queue.
FAQ
Mini Project
Extend the blog API with event-driven cache invalidation. Use Redis pub/sub to propagate invalidation events across instances. When a post is updated, invalidate the post cache, the post list cache, and the author's post list cache. Implement stale-while-revalidate as a fallback.
What's Next
Continue with Write-Through Caching for a detailed exploration of synchronous write policies.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro