DEV Community

Cover image for Caching Is Easy. Cache Invalidation Is Where Systems Break: A Practical Guide
Valipireddy Kowshik
Valipireddy Kowshik

Posted on

Caching Is Easy. Cache Invalidation Is Where Systems Break: A Practical Guide

Adding a cache takes ten minutes. Debugging the bug it causes six months later takes a week.

Caching trades correctness for speed. The senior move is deciding how much correctness you're willing to give up, and making that decision explicit.

Start with the question, not the tool

Before you reach for Redis, answer three things:

  1. How stale can this data be? (seconds, minutes, never)
  2. What does a wrong answer cost? (a flicker, or a wrong invoice)
  3. What's the read-to-write ratio? (caches pay off on read-heavy data)

If you can't answer #1, you're not ready to cache it.

The four patterns

CACHE-ASIDE (lazy)            WRITE-THROUGH
App -> Cache: hit? return     App -> Cache -> DB
      miss -> DB -> fill      (write both, in order)

WRITE-BEHIND                  READ-THROUGH
App -> Cache -> (async) DB    App -> Cache (cache loads DB itself)
Enter fullscreen mode Exit fullscreen mode
Pattern Good for Risk
Cache-aside Most apps, default choice Stale reads, stampedes
Write-through Data read right after write Slower writes
Write-behind Write-heavy, loss-tolerant Data loss if cache dies
Read-through Simplifying app code Provider lock-in

My default: cache-aside with a TTL. It's simple, and failure is safe because the DB stays the source of truth.

Cache-aside in code

async function getUser(id) {
  const key = `user:${id}`;

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

  const user = await db.users.findById(id);
  if (user) {
    // TTL + jitter so keys don't all expire together
    const ttl = 300 + Math.floor(Math.random() * 60);
    await redis.set(key, JSON.stringify(user), "EX", ttl);
  }
  return user;
}

async function updateUser(id, data) {
  await db.users.update(id, data);
  await redis.del(`user:${id}`); // invalidate, don't update
}
Enter fullscreen mode Exit fullscreen mode

Delete on write, don't update. Updating the cache invites race conditions where an older write lands after a newer one.

The three failure modes

1. Cache stampede

A hot key expires and thousands of requests hit the database at once.

t=0   key expires
t=0+  [req][req][req][req][req] -> all miss -> all hit DB -> DB falls over
Enter fullscreen mode Exit fullscreen mode

Fixes:

  • Request coalescing (single flight): one request rebuilds, the rest wait.
  • Lock on rebuild with a short timeout.
  • Stale-while-revalidate: serve the old value while one worker refreshes.
  • TTL jitter, as in the code above.
const inflight = new Map();

function singleFlight(key, loader) {
  if (inflight.has(key)) return inflight.get(key);
  const p = loader().finally(() => inflight.delete(key));
  inflight.set(key, p);
  return p;
}
Enter fullscreen mode Exit fullscreen mode

2. Cache penetration

Requests for keys that don't exist always miss and always hit the DB (bots love this).

Fix: cache the negative result briefly (null with a 30-60s TTL), or put a Bloom filter in front.

3. Stale data after writes

Thread A: reads DB (old)  ............  writes cache (old)
Thread B:        updates DB (new), deletes cache
Result: cache now holds OLD data until TTL
Enter fullscreen mode Exit fullscreen mode

Fixes: short TTLs as a safety net, versioned keys (user:42:v7), or event-driven invalidation from the write path.

Layers matter

Browser cache -> CDN -> API gateway -> App memory -> Redis -> DB
   (fastest, least control) ------------------> (slowest, most control)
Enter fullscreen mode Exit fullscreen mode

Push caching as close to the user as correctness allows. For public, static-ish data, HTTP headers do more than any Redis cluster:

Cache-Control: public, max-age=60, stale-while-revalidate=300
Enter fullscreen mode Exit fullscreen mode

What to measure

  • Hit ratio (below ~80% on a hot path means rethink the key design)
  • p99 latency with and without cache
  • Eviction rate and memory pressure
  • DB load during cold start: can you survive an empty cache?

That last one is the real test. If a cache flush takes you down, your cache isn't an optimization, it's a dependency.

Rules I follow

  • The database is the source of truth, always.
  • Every key has a TTL. No exceptions.
  • Invalidate by deleting, not by updating.
  • Design for a cold cache on day one.
  • Don't cache what you can't afford to be wrong.

Takeaway

There are only two hard things in computer science: cache invalidation and naming things. Treat invalidation as a design problem up front, and your cache stays a speed boost instead of becoming a source of mysterious bugs.

What's the nastiest caching bug you've shipped? Share it in the comments. 👇

Top comments (0)