Skip to content

Cache Invalidation: Strategies for Keeping Cached Data Fresh

DodaTech Updated 2026-06-28 4 min read

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

  1. What is the difference between TTL-based and event-driven invalidation?
  2. What is a cache invalidation storm and how can you prevent it?
  3. How does write-through Caching ensure consistency?
  4. Why should invalidation events be idempotent?
  5. 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

What is cache invalidation?

Cache invalidation is the process of removing or updating stale cache entries when the source data changes, ensuring users see fresh content.

Is TTL-based invalidation sufficient?

It depends on your freshness requirements. For data that changes infrequently and where seconds of staleness is acceptable, TTL works well. For critical data, use event-driven invalidation.

What is a cache invalidation storm?

When many cached entries expire simultaneously (e.g., after a mass cache purge), all requests for those keys hit the origin simultaneously, potentially overwhelming it.

What is the difference between cache invalidation and eviction?

Invalidation is explicit removal due to data change. Eviction is automatic removal due to space constraints or TTL expiry. Both result in the cache entry being removed.

How do I invalidate related cache entries?

Use cache tagging: assign tags to related entries (e.g., 'user:123', 'feed'). When data changes, invalidate all entries with matching tags. Redis can implement this with sets.

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