StackPractices
intermediate By Mathias Paulenko

Cache Invalidation Pattern

Strategies for keeping cached data fresh: TTL expiration, explicit invalidation, write-through, and event-driven cache eviction.

Overview

Cache invalidation removes or updates stale entries so subsequent reads fetch fresh data. Without it, a cached value can keep serving outdated data forever. What’s genuinely hard is figuring out when data changed and reacting fast, without piling overhead onto every request.

There are four main invalidation strategies: TTL-based expiration, explicit invalidation on write, event-driven invalidation via pub/sub, and versioned keys. Each trades consistency guarantees against implementation complexity. The Redis caching patterns documentation covers the eviction mechanics underneath all of them.

When to Use

  • Cached data changes in the database and stale reads cause correctness issues
  • You need to invalidate specific keys, not the entire cache
  • More than one application instance must stay in sync when one of them modifies data
  • TTL alone is insufficient because the staleness window is too long
  • You want to minimize cache misses while keeping data fresh
  • You can skip all of this when entries are write-once: immutable data never goes stale, so invalidation is dead weight

Solution

Strategy 1: TTL-Based Expiration

Set a TTL on every cache entry and you’re mostly done: expired entries get evicted on their own, so the next read misses and has to reload fresh data. If that flow sounds familiar, it’s the Cache-Aside Pattern.

import redis
import json

# decode_responses=True so r.get() returns str instead of bytes
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def get_user(user_id: str) -> dict:
    key = f"user:{user_id}"
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    user = db.query_one("SELECT * FROM users WHERE id = %s", [user_id])
    r.setex(key, 300, json.dumps(user))  # 5-minute TTL
    return user

Strategy 2: Explicit Invalidation on Write

After updating the database, delete the corresponding cache key, so the next read misses and reloads from the DB.

def update_user(user_id: str, name: str, email: str):
    db.execute(
        "UPDATE users SET name = %s, email = %s WHERE id = %s",
        [name, email, user_id]
    )
    # Invalidate the cache entry
    r.delete(f"user:{user_id}")
    # Also invalidate any derived caches
    r.delete(f"user:{user_id}:profile")
    r.delete(f"users:list")

Strategy 3: Event-Driven Invalidation with Redis Pub/Sub

When one instance updates data, it publishes an invalidation event. All instances subscribe and clear their local caches.

import redis
import json
import threading

class CacheInvalidator:
    def __init__(self, redis_client: redis.Redis):
        self.redis = redis_client
        self.pubsub = redis_client.pubsub()
        self.pubsub.subscribe("cache:invalidate")
        self._listener = threading.Thread(target=self._listen, daemon=True)
        self._listener.start()

    def invalidate(self, key: str):
        self.redis.delete(key)
        self.redis.publish("cache:invalidate", json.dumps({"key": key}))

    def invalidate_pattern(self, pattern: str):
        for key in self.redis.scan_iter(pattern):
            self.redis.delete(key)
        self.redis.publish("cache:invalidate", json.dumps({"pattern": pattern}))

    def _listen(self):
        for message in self.pubsub.listen():
            if message["type"] != "message":
                continue
            data = json.loads(message["data"])
            if "key" in data:
                self.redis.delete(data["key"])
            elif "pattern" in data:
                for key in self.redis.scan_iter(data["pattern"]):
                    self.redis.delete(key)


invalidator = CacheInvalidator(redis.Redis(host='localhost', port=6379))

def update_user(user_id: str, name: str, email: str):
    db.execute(
        "UPDATE users SET name = %s, email = %s WHERE id = %s",
        [name, email, user_id]
    )
    invalidator.invalidate(f"user:{user_id}")

TypeScript with Redis Pub/Sub

import { createClient } from 'redis';

class CacheInvalidator {
  private publisher: ReturnType<typeof createClient>;
  private subscriber: ReturnType<typeof createClient>;

  constructor(publisher: ReturnType<typeof createClient>, subscriber: ReturnType<typeof createClient>) {
    this.publisher = publisher;
    this.subscriber = subscriber;
    this.subscriber.subscribe('cache:invalidate', (message) => {
      const data = JSON.parse(message);
      if (data.key) {
        this.publisher.del(data.key);
      } else if (data.pattern) {
        // Delete all matching keys
        this.publisher.keys(data.pattern).then((keys) => {
          if (keys.length > 0) this.publisher.del(keys);
        });
      }
    });
  }

  async invalidate(key: string): Promise<void> {
    await this.publisher.del(key);
    await this.publisher.publish('cache:invalidate', JSON.stringify({ key }));
  }

  async invalidatePattern(pattern: string): Promise<void> {
    const keys = await this.publisher.keys(pattern);
    if (keys.length > 0) await this.publisher.del(keys);
    await this.publisher.publish('cache:invalidate', JSON.stringify({ pattern }));
  }
}

Strategy 4: Versioned Keys

Instead of invalidating, change the key name. The old key then just expires via TTL on its own, so no read ever races the invalidation.

def get_user_version(user_id: str) -> str:
    # With decode_responses=True this returns a str; without it you'd get b"1"
    # and the key would become user:123:vb'1'
    version = r.get(f"user:{user_id}:version")
    if version is None:
        version = "1"
        r.set(f"user:{user_id}:version", version)
    return version

def get_user(user_id: str) -> dict:
    version = get_user_version(user_id)
    key = f"user:{user_id}:v{version}"

    cached = r.get(key)
    if cached:
        return json.loads(cached)

    user = db.query_one("SELECT * FROM users WHERE id = %s", [user_id])
    r.setex(key, 300, json.dumps(user))
    return user

def update_user(user_id: str, name: str, email: str):
    db.execute(
        "UPDATE users SET name = %s, email = %s WHERE id = %s",
        [name, email, user_id]
    )
    # Bump the version — the old key becomes orphaned and expires via TTL
    r.incr(f"user:{user_id}:version")

Explanation

Here’s how the four strategies trade implementation complexity against consistency:

Cache invalidation flow: writes invalidate the key and publish an event to all instances; reads hit the cache or reload from the database with a TTL
  • TTL: the cache entry expires after a fixed duration. Simple, but data can be stale for up to the TTL duration. Good for data that changes infrequently or where staleness is acceptable.

  • Explicit invalidation: the write path deletes the cache key after updating the database, so the staleness window is near zero. The risk is a forgotten key: a write that misses its invalidation leaves stale data until TTL expires.

  • Event-driven: when several instances share a cache, one instance’s invalidation has to reach all the others. Redis pub/sub broadcasts the invalidation event and every subscriber deletes the key, which keeps the whole cluster consistent.

  • Versioned keys: instead of deleting a key, the write path increments a version counter. The next read builds a new key with the new version, and the old key is orphaned until TTL expires. This avoids the race where a read loads stale data between the database write and the cache delete.

None of these strategies changes the read path. Every read still checks the cache first, misses to the database on an absent or expired key, and writes the result back — the cache-aside flow. What differs is who removes the stale entry and how fast. Keep that in mind when measuring staleness: the metric that matters is the gap between the database commit and the moment the last instance drops the old value, not the TTL itself.

One more interaction worth knowing: invalidating a hot key recreates the stampede problem, because every request that arrives before the first reload finishes misses simultaneously. If a single key absorbs most of your traffic, pair invalidation with the lock/single-flight mechanism from the stampede-prevention pattern rather than adding more machinery on top.

Variants

StrategyConsistencyComplexityBest For
TTL expirationEventual (within TTL)LowInfrequently changing data
Explicit invalidationStrong (on next read)MediumMost use cases
Event-driven (pub/sub)Strong (cluster-wide)HighMulti-instance deployments
Versioned keysStrong (no race)MediumHigh-concurrency reads
Tag-based invalidationStrong (grouped)HighRelated cache entries

These strategies aren’t mutually exclusive. A typical production cache layers two or three of them: explicit invalidation on the write path for correctness, tag-based invalidation for grouped entities, and a TTL on every key as the safety net that catches anything the other two miss. Versioned keys tend to replace explicit deletes rather than stack with them, since both solve the same stale-read race.

Pick the TTL from the business side, not the infrastructure side: ask how old a value may be before it causes a user-visible problem, then set the TTL slightly below that bound. A product catalog that tolerates a five-minute lag gets a 300-second TTL with explicit invalidation on product updates; a session store gets a short TTL and nothing else, because every write already flows through the cache.

Best Practices

  • If stampedes are your actual problem, the Cache Stampede Prevention Pattern goes deeper. The Azure docs on Cache-Aside describe the load-on-miss read path all of these strategies rely on.

  • Always set a TTL as a safety net: even with explicit invalidation, a TTL catches missed invalidations. Set it to the maximum acceptable staleness duration.

  • Invalidate derived caches: if you cache user:123 and also user:123:profile, invalidate both. Track dependencies or use tag-based invalidation.

  • Invalidate after the database write: don’t touch the cache key until the DB update has succeeded. If you delete before and the DB write fails, the next read reloads stale data.

  • Use pub/sub for multi-instance setups: when several app instances share one Redis cache, pub/sub is what keeps each instance’s local state in sync.

  • Monitor invalidation rate: if you invalidate more often than you hit, the cache isn’t paying for itself. Consider longer TTLs or write-through caching.

Common Mistakes

  • Forgetting to invalidate: the most common cache bug. Every write path must invalidate the corresponding cache keys. Use a wrapper or an ORM hook so it can’t be skipped.
  • Invalidating before the DB write: if the DB write fails, you cleared the cache for nothing and the next read reloads the old data. Always invalidate after the write succeeds.
  • Not invalidating list caches: updating one user invalidates user:123, but users:list still contains the old record. Invalidate list and aggregate caches too.
  • Relying only on TTL: with a 5-minute TTL your data can sit stale for 5 minutes. Add explicit invalidation for anything time-sensitive.
  • Race condition on invalidate: thread A reads from the DB, thread B writes and deletes the key, then thread A writes stale data back into the cache. Use versioned keys or locks to prevent this.

Advanced Topics

Scenario: Cache Invalidation for E-commerce

// Cache invalidation strategies in one manager
class CacheManager {
  constructor(private redis: RedisClient) {}

  // Every cached key registers the tags it belongs to
  async setWithTags(key: string, value: unknown, tags: string[]): Promise<void> {
    await this.redis.setex(key, 300, JSON.stringify(value));
    for (const tag of tags) {
      await this.redis.sadd(`tag:${tag}`, key);
    }
  }

  // 1. Explicit invalidation: delete the key plus every tagged group.
  // Note: DEL does not glob — `del("products:category:*")` would only
  // remove a key literally named "products:category:*".
  async invalidateProduct(productId: string, categoryId: string): Promise<void> {
    await this.redis.del(`product:${productId}`);
    await this.invalidateByTag(`product:${productId}`);
    await this.invalidateByTag(`category:${categoryId}`);
  }

  // 2. Tag-based invalidation: delete every key registered under a tag
  async invalidateByTag(tag: string): Promise<void> {
    const keys = await this.redis.smembers(`tag:${tag}`);
    if (keys.length > 0) await this.redis.del(...keys);
    await this.redis.del(`tag:${tag}`);
  }

  // 3. Version-based invalidation: bump the namespace version
  async bumpVersion(namespace: string): Promise<void> {
    await this.redis.incr(`version:${namespace}`);
  }

  async getWithVersion<T>(namespace: string, key: string): Promise<T | null> {
    const version = (await this.redis.get(`version:${namespace}`)) ?? "1";
    const fullKey = `${namespace}:${version}:${key}`;
    const cached = await this.redis.get(fullKey);
    return cached ? JSON.parse(cached) : null;
  }

  // 4. Write-through: refresh the cache right after the DB write
  async updateProduct(product: Product): Promise<void> {
    await db.update(product);
    await this.setWithTags(`product:${product.id}`, product, [
      `product:${product.id}`,
      `category:${product.categoryId}`,
    ]);
  }
}

Lessons:

  • Explicit invalidation: delete the key on update
  • Tag-based invalidation: register keys under tags, then delete the whole group in one call
  • Version-based: bump the version and let old keys expire by TTL
  • Write-through: refresh the cache on the DB write for maximum consistency
  • TTL as a safety net: if invalidation misses, TTL cleans up anyway
  • The hardest problem in caching is deciding when to invalidate

Companion code: the cache-invalidation-pattern companion repo has runnable Python and TypeScript versions of every strategy on this page.

Frequently Asked Questions

What is the best cache invalidation strategy?

It really depends on your case: how fresh the data must be, how often it changes, and how many app instances share the cache. What does work almost everywhere is TTL as the baseline safety net. From there, add explicit invalidation for data that changes and needs to be fresh immediately, event-driven invalidation for multi-instance deployments, and versioned keys for high-concurrency scenarios where a race would actually hurt.

What is the cache stampede problem?

When a popular cache key expires, every concurrent request misses at once and the database gets flooded. This isn't an invalidation problem but a TTL expiration problem, so the fix is the cache-stampede-prevention pattern (locks, single-flight).

How does tag-based invalidation work?

Tags let you group related entries: you'd store user:123 with tags like ["user", "user:123"], and when that user changes you wipe everything carrying the user:123 tag, derived caches included. Redis doesn't ship tag support natively; libraries like redis-tag-cache add it.

Should I invalidate or update the cache on write?

Deleting is the simpler and safer default: the next read just reloads the fresh row. Updating the cache on write (write-through) makes that next read faster but risks storing stale data if the DB write and the cache update aren't atomic. Prefer invalidation unless write-through is explicitly needed.

How do I handle cache invalidation in microservices?

Publish a domain event (for example ProductUpdated) when a service writes data, and let every service that caches that data subscribe and evict its local copy. For a shared Redis cache, pub/sub works the same way: the writer publishes the invalidation and each subscriber deletes the key. Use a short TTL (~60s) plus events for eventual consistency, or write-through plus synchronous invalidation when you need strong consistency. Avoid cascade invalidation, where one event fans out into many deletes — it can cause a thundering herd against the database.