CodingNic

Caching Strategies

Cache Invalidation

Caching Strategies 15 min read

Cache Invalidation

Objectives

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

  • Explain why an expiration alone isn’t always enough
  • Explicitly invalidate a cache entry when the underlying data changes
  • Confirm a stale cache entry is never served after a write

💡 Why this matters: There’s a well-known saying, cache invalidation is one of the genuinely hard problems in computer science, not because the mechanism is complicated, but because getting it wrong means confidently serving data that’s already wrong, invisibly, until someone notices.

⚠️ A note on verification: as in the last lesson, this uses ioredis-mock, an in-memory library implementing Redis’s real client behavior, the caching and invalidation logic itself is genuinely tested against it.

Expiration Alone

javascript
await redis.set('session:abc', 'user-data', 'EX', 1);
console.log('Immediately after set:', await redis.get('session:abc'));
console.log('TTL remaining:', await redis.ttl('session:abc'), 'seconds');

await new Promise((resolve) => setTimeout(resolve, 1200));
console.log('After 1.2 seconds:', await redis.get('session:abc'));
text
Immediately after set: user-data
TTL remaining: 1 seconds
After 1.2 seconds: null

A TTL (time-to-live) alone eventually clears stale data, here, after just one second, but “eventually” is doing a lot of work in that sentence, a 60-second TTL (Lesson 2) means up to 60 seconds of serving data that might already be wrong, fine for some data, not for data that just changed as a direct result of the current request.

Explicit Invalidation on Write

javascript
async function updateUserBio(id, newBio) {
  users[id].bio = newBio; // simulate a real database write
  await redis.del(`user:${id}`); // invalidate the stale cache entry
}

Whenever the underlying data changes, delete the corresponding cache entry immediately, redis.del(...), don’t wait for the TTL to catch up, this is the other half of cache-aside, not just populating the cache on a miss (Lesson 2), but actively clearing it the moment it’s known to be wrong.

Confirming It End to End

javascript
console.log('--- initial read, populates cache ---');
console.log(await getUser(1));

console.log('--- read again, should come from cache, old bio ---');
console.log(await getUser(1));

console.log('--- updating bio, invalidating cache ---');
await updateUserBio(1, 'Senior backend developer');

console.log('--- read again, should miss cache, fetch fresh data ---');
console.log(await getUser(1));
text
--- initial read, populates cache ---
{ data: { id: 1, email: 'erin@example.com', bio: 'Backend developer' }, source: 'database' }

--- read again, should come from cache, old bio ---
{ data: { id: 1, email: 'erin@example.com', bio: 'Backend developer' }, source: 'cache' }

--- updating bio, invalidating cache ---

--- read again, should miss cache, fetch fresh data ---
{ data: { id: 1, email: 'erin@example.com', bio: 'Senior backend developer' }, source: 'database' }

--- read again, should now come from cache, new bio ---
{ data: { id: 1, email: 'erin@example.com', bio: 'Senior backend developer' }, source: 'cache' }

The updated bio, 'Senior backend developer', is genuinely never served stale, the write immediately invalidated the cache entry, the very next read correctly missed and fetched fresh data, repopulating the cache with the new value, this is the property that actually matters, not just “eventually correct,” but “never wrong after the update that’s supposed to fix it.”

TTL and Explicit Invalidation Together

Real applications use both: a TTL as a safety net, in case an invalidation is ever missed somewhere in the codebase, and explicit invalidation for anything the application already knows changed. Relying on a TTL alone means accepting up to that TTL’s worth of staleness on every write, relying on invalidation alone means one missed redis.del() call, somewhere, leaves a cache entry stale forever, using both is what most real systems actually do.

Try It

  1. Build updateUserBio, and confirm a read immediately after an update never returns the old, stale bio.
  2. Remove the redis.del(...) call from updateUserBio, rerun the same sequence, and confirm the bug this lesson exists to prevent, a stale bio served after the update.
  3. Set a very short TTL (1 second) on a cached value, and confirm it expires and disappears on its own, without any explicit invalidation.
  4. Explain, in one or two sentences, why relying on a TTL alone is risky specifically for data that changes as a direct result of a write the application itself just performed.

Recap

  • A TTL alone eventually clears stale data, but “eventually” can mean serving wrong data for the entire remaining duration.
  • Explicit invalidation, redis.del() on a write, clears a cache entry the moment it’s known to be stale, confirmed here with a bio update never served stale on the very next read.
  • Real systems typically use both, a TTL as a safety net, explicit invalidation for anything the application already knows has changed.

This is the final lesson of this module before exercises. Next lesson: exercises, building a complete cache-aside implementation with both expiration and invalidation.