Why cache invalidation is hard

Why cache invalidation is hard, for real: stale reads, freshness vs correctness, copies scattered across layers, races, and knowing what to clear.

There is a famous line from Phil Karlton, an engineer at Netscape:

There are only two hard things in Computer Science: cache invalidation and naming things.

It sounds like a joke, and it is repeated like one. But the first half is serious, and this lesson is about why. You already know the two ways to keep a cache fresh from TTL vs explicit invalidation. Invalidation is the active one: when the real data changes, you go clear the stale copy. That sounds simple. It is not. Let us see exactly where the difficulty hides.

A cache is a copy, and copies drift

Caching means keeping a second copy of some data so you can read it fast without asking the database every time. You saw the reason in why we cache.

The moment you make a copy, you have two sources of truth: the database (the real one) and the cache (the fast one). As long as nothing changes, they agree. The trouble starts the instant the real data changes. Now the copy is stale: it says one thing, the database says another, and your users are reading the copy.

Database:  name = "Ada Lovelace"   (just updated)
Cache:     name = "Ada Byron"      (the old copy, still being served)

A stale read is when a user sees the cached copy after the real data has moved on. That is the whole problem in one sentence. Everything else is a reason why fixing it is harder than it looks.

Reason 1: freshness fights correctness

You cache to be fast. You invalidate to be correct. These two goals pull in opposite directions.

If you never invalidate, the cache is fast but wrong. If you invalidate on every tiny change, you throw away the copy so often that you are barely caching at all - back to hammering the database. Every caching decision lives on this tension: how stale is too stale for this particular piece of data? There is no universal answer, which is why you have to think about it case by case.

Reason 2: the copy is not in one place

In a small app the cache is one Redis key. In a real system, a single piece of data can be copied into many places at once:

  • Redis holds the cached value.
  • The rendered HTML page sits in a CDN or page cache.
  • A smaller fragment (a header showing the user's name) is cached separately.
  • The user's browser has its own copy.

Update the name in the database and every one of those copies is now stale. Clearing the Redis key does not touch the CDN. Clearing the CDN does not touch the browser. To invalidate correctly you have to know every layer that copied the data - and it is easy to forget one.

Reason 3: races in the gap

Invalidation is not instant. There is a tiny gap between "write the new data" and "clear the old cache," and in that gap another request can slip in.

Request A: writes new name to the database
Request B: cache miss, reads the OLD name (A has not cleared it yet)
Request A: clears the cache key
Request B: stores the OLD name it just read  <-- stale copy is back

Request B rebuilt the cache from data it read before the write landed, and it did so after A cleared the key. Now the stale value is cached again, and nothing will clear it until the next write or the TTL. This is a race condition, and it is why order matters: whether you clear before or after the write, some interleaving can still lose. Distributed systems make this worse because the requests may run on different servers entirely.

Reason 4: the real hard part - what do I even clear?

The reasons above are mechanical. This one is the deep one, and it is what Karlton meant.

The hard question is not how to clear a key. It is knowing which keys hold a copy of the data that just changed. Real data has dependencies, and one small edit ripples outward.

A worked example

A user changes their display name from "Ada Byron" to "Ada Lovelace." One row in one table. Now, honestly, how many cached things just went stale?

  • user:42:profile - the profile itself. Obvious.
  • posts.latest - the homepage list of recent posts, each showing its author's name.
  • post:99:rendered - the full HTML of every article she wrote, with her byline baked in.
  • comments:recent - the sidebar of recent comments, showing commenter names.
  • leaderboard:top - a "top authors" widget listing her.
  • The search index entry, the RSS feed, the email digest queued for tonight...

One tiny edit, and a dozen cached values across the app are now wrong. Finding all of them is the hard problem. Miss one, and users see the old name in that one spot - the profile updates but the byline on her articles does not, and it looks like a bug because it is one.

And notice: nothing in the code tells you these keys are related. The database row does not know the homepage cached it. You, the developer, have to hold that whole dependency map in your head and remember it every time you write invalidation code. Six months later, someone adds a new cached widget that also shows the name, and forgets to wire it into the invalidation. Now it is stale forever.

There is one more thing that makes this uniquely nasty, and it is the reason invalidation bugs reach production so often: a stale read fails silently. A missed invalidation does not throw. It does not log anything. Nothing turns red. The code runs exactly as written and happily serves a value that is simply wrong. Your tests pass, because a test seeds the data, reads it once, and never waits in the gap where the copy drifts. So these bugs are almost never caught by the machine - they are caught by a user noticing their own name is wrong on one page, and reported as "the site is glitchy." That silence is why you cannot lean on "I will notice if it breaks." You will not. Nothing breaks; it just lies.

So what do we do about it?

You do not solve this by being clever once. You solve it by choosing strategies that shrink the problem:

  • Give cached values a TTL so a mistake self-heals instead of lasting forever (the safety net from the previous lesson).
  • Group related keys so you can clear them together instead of hunting each one - that is what cache tags are for.
  • Bust the cache automatically when the model changes, so nobody has to remember.

The next lesson walks through these strategies one by one. The point of this lesson is that you now respect the problem: invalidation is hard because a single change can touch many copies you did not write down, and forgetting one shows real, wrong data to real people.

Common mistake

Treating invalidation as an afterthought - "I will just clear the key when I save." You clear the one key you were thinking about and ship it. The stale bylines, the stale sidebar, the stale digest all slip through, because you invalidated the copy you remembered and forgot the five you did not. The fix is not more discipline; it is structure (TTLs and tags) so you do not have to remember every copy by hand.

FAQ

Is cache invalidation really that hard, or is it a meme?

Both. The quote is repeated for fun, but the difficulty is genuine: a single write can invalidate copies scattered across Redis, CDNs, fragments, and browsers, and there is no automatic list of what those copies are. The famous part is real.

If it is so hard, should I just not cache?

No. Caching is often the difference between a fast app and a slow one. The lesson is not to avoid caching - it is to cache deliberately, always with a TTL, and to use tools that clear related copies together instead of relying on memory.

Why not just clear the whole cache on every change?

Because that throws away every other cached value too, so the next requests all miss and hit the database at once - you traded a correctness bug for a performance one. You want to clear related copies, not everything, which is exactly the balance the next lesson is about.