Skip to main content

🧹 Cache Invalidation

A cache makes reads fast by keeping a copy of the truth. The moment the truth changes, that copy becomes a lie β€” and deciding when and how to clear it is famously one of the hardest problems in the field. This lesson gives you the practical strategies to keep your cache honest without drowning in complexity.

Week 10 · Thursday: Caching Strategies · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why cache invalidation is hard and distinguish it from cache expiration
  • Apply TTL-based invalidation and know its staleness trade-off
  • Implement event-based invalidation that deletes keys on data change
  • Use versioned keys to sidestep deletes and race conditions
  • Invalidate groups of related keys safely with SCAN, not KEYS
  • Coordinate invalidation across services with Redis pub/sub

Estimated Time: 70 minutes

Project: Add correct invalidation to a product API so an update is instantly reflected, then coordinate it across two services.

In This Lesson

Why It's Hard

"There are only two hard things in Computer Science: cache invalidation and naming things." β€” Phil Karlton

It's a joke, but it's on the syllabus for a reason. Invalidation is hard because it forces you to track something slippery: every cache entry that might be affected by a change, across code that may live in different files, services, or even data centers. Miss one, and a user sees a deleted product. Clear too many, and you throw away the performance you built the cache for.

graph TD Change["Data changes in the database"] --> Q1["Which cached keys are now wrong?"] Change --> Q2["When exactly do we clear them?"] Change --> Q3["Who clears them if there are many services?"] Q1 --> Risk["Miss one and users see stale data"] Q2 --> Risk Q3 --> Risk

Invalidation vs. expiration

Two related but distinct ideas β€” you'll use both together:

What it isTriggered by
ExpirationA key auto-deletes after a TTLThe clock
InvalidationYou proactively clear a key when its data changesA write / event

The consistency spectrum

Not all data needs the same rigor. Match your effort to the cost of being wrong:

  • Strong consistency β€” the cache must never disagree with the source (account balances, permissions). Invalidate aggressively on every write.
  • Eventual consistency β€” brief staleness is fine and self-corrects (a "likes" count). A short TTL is often enough.
  • Weak consistency β€” staleness barely matters (a view counter, a trending list). A long TTL and a lazy refresh will do.

TTL: The Baseline

The simplest invalidation strategy is to not invalidate at all β€” just let every key expire on a timer. It requires zero coordination and is the right default for anything that tolerates a little staleness.

// A cached read where the ONLY invalidation is the TTL
async function getUserProfile(userId) {
  const key = `user:${userId}:profile`;

  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const profile = await db.users.findOne({ _id: userId });
  if (profile) {
    // After 30 minutes this entry disappears and the next read reloads it.
    await redis.set(key, JSON.stringify(profile), { EX: 1800 });
  }
  return profile;
}
πŸ‘ AdvantagesπŸ‘Ž Trade-offs
Dead simple, no coordination between servicesData can be stale for up to the full TTL
Self-cleaning β€” rarely-used keys evict themselvesRefreshes even when nothing changed (wasted work)
A safety net even alongside active invalidationChoosing the "right" TTL is guesswork

πŸ’‘ TTL is a floor, not a ceiling

Even when you add smarter invalidation below, keep a TTL on every key. It's the backstop that guarantees any entry you forgot to clear can only be wrong for a bounded time. Active invalidation makes the cache fresh fast; TTL guarantees it becomes fresh eventually.

Event-Based Invalidation

When staleness isn't acceptable, tie invalidation to the write. The rule of thumb: the code that changes the data is responsible for clearing its cache. Update the database, then delete the affected keys in the same operation.

sequenceDiagram participant Client as Client participant App as API Server participant DB as Database participant Cache as Redis Client->>App: Update the product App->>DB: Write the change DB->>App: Acknowledge the write App->>Cache: Delete the affected keys App->>Client: Respond with the updated product
// Express route: update a product and invalidate its cache
app.put('/api/products/:id', async (req, res) => {
  try {
    const { id } = req.params;

    // 1. Write to the source of truth
    const updated = await db.products.findOneAndUpdate(
      { _id: id },
      { $set: req.body },
      { returnDocument: 'after' }
    );
    if (!updated) return res.status(404).json({ error: 'Not found' });

    // 2. Clear every cache entry this product could appear in
    await redis.del([
      `product:${id}`,                          // the product itself
      `products:category:${updated.category}`,  // its category listing
      'products:featured',                      // the featured list
      'products:recent'                         // the recents list
    ]);

    res.json(updated);
  } catch (err) {
    console.error('Update failed:', err);
    res.status(500).json({ error: 'Update failed' });
  }
});

⚠️ The hard part: derived data

Deleting product:42 is easy. The trap is everywhere else that product appears β€” category pages, search results, the homepage's "featured" block, a user's recently-viewed list. Miss one and it silently serves the old data. This is exactly why Karlton's quote endures: correctness depends on you enumerating relationships a machine won't remind you about. Consistent key naming (below) makes this tractable.

Advantages: the cache is consistent almost immediately after a change. Trade-offs: you must track which keys to clear, and it couples every write path to your cache layout.

Versioned Keys

What if you never had to delete anything? With versioned keys, you bump a version number when data changes, which instantly makes every old key unreachable. The stale entries still exist for a moment, but nobody looks them up β€” and their TTL sweeps them away.

class VersionedCache {
  constructor(redis) {
    this.redis = redis;
  }

  // The real cache key includes the current version, e.g. user:5:profile:v3
  async versionedKey(baseKey) {
    const version = (await this.redis.get(`ver:${baseKey}`)) || '1';
    return `${baseKey}:v${version}`;
  }

  async get(baseKey) {
    const key = await this.versionedKey(baseKey);
    const raw = await this.redis.get(key);
    return raw ? JSON.parse(raw) : null;
  }

  async set(baseKey, data, ttl = 3600) {
    const key = await this.versionedKey(baseKey);
    await this.redis.set(key, JSON.stringify(data), { EX: ttl });
  }

  // "Invalidate" = bump the version. Old vN keys are now orphaned
  // and expire on their own. No delete, no scan, no race.
  async invalidate(baseKey) {
    return this.redis.incr(`ver:${baseKey}`);
  }
}

// Usage
const cache = new VersionedCache(redis);
await cache.set('user:5:profile', profile);   // writes ...:v1
await cache.invalidate('user:5:profile');     // version -> 2
// Next read builds ...:v2, misses, and reloads fresh data.

βœ… Why versioning shines

It's race-condition resistant: bumping a counter is atomic, and there's no window where a concurrent writer and reader fight over a deleted key. It also works cleanly across distributed nodes β€” every reader computes the same versioned key. The cost is a little extra storage for version counters and short-lived orphaned entries.

Invalidating Groups of Keys

Sometimes one change should clear many related keys β€” every cached page of a category, say. The instinct is KEYS pattern, and it's a trap: KEYS scans the entire database and blocks Redis's single thread while it does. Use SCAN, which iterates in small non-blocking chunks.

// ❌ NEVER in production β€” blocks the whole server
// const keys = await redis.keys('product:category:5:*');

// βœ… SCAN iterates lazily; delete matches as you go
async function invalidatePattern(pattern) {
  let deleted = 0;
  for await (const key of redis.scanIterator({ MATCH: pattern, COUNT: 100 })) {
    await redis.del(key);
    deleted++;
  }
  console.log(`Invalidated ${deleted} keys matching ${pattern}`);
  return deleted;
}

// Clear every cached listing page for a category
await invalidatePattern('products:category:5:page:*');

πŸ“– A cleaner alternative: tag sets

Scanning is still O(keyspace). A tidier approach is to track membership yourself: when you cache a key, also sAdd it to a Redis set that names its group. To invalidate the group, read the set's members, delete them, then delete the set β€” no scanning of unrelated keys at all.

// On cache write, record the key under its group tag
await redis.set('products:category:5:page:1', json, { EX: 3600 });
await redis.sAdd('tag:category:5', 'products:category:5:page:1');

// On invalidate, clear exactly the tagged keys
async function invalidateTag(tag) {
  const keys = await redis.sMembers(tag);
  if (keys.length) await redis.del(keys);
  await redis.del(tag);
}
await invalidateTag('tag:category:5');

Coordinating Across Services

In a single app, deleting a key is a local call. In a microservice or multi-instance deployment, the service that changes the data may not be the one holding the stale cache. Redis pub/sub broadcasts an invalidation event so every instance clears its copy.

graph LR Writer["Order Service
(changes data)"] -->|"publish event"| Channel(("cache:invalidate
channel")) Channel -->|"subscribed"| A["API Instance A"] Channel -->|"subscribed"| B["API Instance B"] Channel -->|"subscribed"| C["API Instance C"]
const CHANNEL = 'cache:invalidate';

// --- Publisher: any service that mutates data ---
async function publishInvalidation(type, id) {
  const message = JSON.stringify({ type, id, at: Date.now() });
  await redis.publish(CHANNEL, message);
}

// --- Subscriber: every instance that holds a cache ---
async function startInvalidationListener() {
  // A subscriber connection can't run normal commands, so duplicate the client.
  const subscriber = redis.duplicate();
  await subscriber.connect();

  await subscriber.subscribe(CHANNEL, async (message) => {
    const { type, id } = JSON.parse(message);
    await redis.del(`${type}:${id}`);

    // Clear related derived keys too
    if (type === 'product') {
      await redis.del(['products:featured', 'products:recent']);
    }
    console.log(`Invalidated ${type}:${id} from broadcast`);
  });

  console.log('Listening for invalidation events');
}

// When the order service updates a product:
await db.products.updateOne({ _id: id }, { $set: changes });
await publishInvalidation('product', id);   // every instance clears its cache

⚠️ Pub/sub is fire-and-forget

Redis pub/sub delivers to whoever is currently subscribed. An instance that's restarting or briefly disconnected misses the message and keeps stale data until its TTL saves it. For guaranteed delivery, use Redis Streams (with consumer groups and acknowledgements) instead of plain pub/sub β€” and always keep that TTL backstop.

Practice & Quiz

πŸ‹οΈ Exercise 1: Invalidate on update

Goal: Complete updateUser so that after writing to the database it clears both the user's main cache key and their profile key.

async function updateUser(userId, changes) {
    await db.users.updateOne({ _id: userId }, { $set: changes });
    // TODO: invalidate `user:${userId}` and `user:${userId}:profile`
}
πŸ’‘ Hint

redis.del accepts an array of keys and clears them in one call.

βœ… Solution
async function updateUser(userId, changes) {
    await db.users.updateOne({ _id: userId }, { $set: changes });
    await redis.del([`user:${userId}`, `user:${userId}:profile`]);
}

Write the database first, then invalidate β€” so a reader can never repopulate the cache from stale data mid-update.

πŸ‹οΈ Exercise 2: Fix a blocking invalidation

Goal: This code clears a category's pages but will freeze Redis under load. Rewrite it to be non-blocking.

async function clearCategory(catId) {
    const keys = await redis.keys(`category:${catId}:*`); // ❌
    if (keys.length) await redis.del(keys);
}
βœ… Solution
async function clearCategory(catId) {
    for await (const key of redis.scanIterator({
        MATCH: `category:${catId}:*`, COUNT: 100
    })) {
        await redis.del(key);
    }
}

KEYS blocks the single-threaded server while it walks every key; SCAN streams matches in small chunks so other clients keep working.

🎯 Quick Quiz

Question 1: What is the key difference between cache expiration and cache invalidation?

Question 2: Why is KEYS pattern dangerous in production?

Question 3: A key advantage of versioned keys over deleting keys is that they…

Best Practices & Pitfalls

βœ… Do

  • Keep invalidation logic next to the write that causes it.
  • Use a consistent, hierarchical key naming scheme so related keys are easy to find and clear.
  • Always keep a TTL as a safety net, even with active invalidation.
  • Be pessimistic: when unsure whether a change affects a key, clear it β€” a cache miss beats stale data.
  • Prefer versioned keys or tag sets over pattern scans for grouped invalidation.

❌ Don't

  • Run KEYS in production β€” use SCAN or tracked tag sets.
  • Forget derived and aggregated data (lists, search results, counts) when clearing a record.
  • Rely on plain pub/sub for guaranteed delivery β€” subscribers that are down miss the message.
  • Invalidate a hugely popular key without stampede protection (recall last lesson's lock and stale-while-revalidate).
  • Over-invalidate: clearing far more than changed quietly destroys your hit rate.

⚠️ Watch your hit rate

The two failure modes are mirror images. Under-invalidation serves stale data β€” a correctness bug. Over-invalidation throws away good cache entries β€” a performance bug that can be invisible until traffic spikes. Monitor your cache hit rate; a sudden drop after a deploy usually means someone's invalidation got too broad.

Summary

πŸŽ‰ Key Takeaways

  • Invalidation is hard because you must track every key a change could affect β€” including derived data.
  • Expiration is clock-driven; invalidation is change-driven. Use both.
  • TTL is the zero-coordination baseline and the safety net under everything else.
  • Event-based deletes keep the cache fresh immediately after a write.
  • Versioned keys dodge deletes and races; SCAN or tag sets (never KEYS) clear groups; pub/sub coordinates across services.

πŸ“š Additional Resources

πŸš€ What's Next?

Write-behind caching and cross-service invalidation both leaned on a queue to do work reliably in the background. That idea deserves its own lesson: Queue Systems β€” decoupling slow or async work from the request/response cycle.

🧹 Cache kept honest!

You've faced one of computing's two hard problems and come out with a real toolkit. Onward to background work.