DEV Community

Muralidharan Lakshmanan
Muralidharan Lakshmanan

Posted on

The Hardest Problem in Caching: Invalidation

When we started this series, caching sounded simple: put the data somewhere faster, get a faster response. But there's a question we haven't fully answered yet.

What happens when the data in the database changes?

Say the database has product:123 at name = "Laptop", price = $999. We put it in the cache. Everything works perfectly. Then the business changes the price to $899 — but the cache still has $999.

Once you cache it, you have two versions of reality: the database says price = $899 and is the source of truth, while the cache still says price = $999, a copy made earlier that nothing has told about the change

This is stale data, and it leads us to one of the most famous lines in software engineering — often attributed to Phil Karlton:

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

The reason it's hard is simple: a cache creates another copy of your data, and once you have multiple copies, keeping them synchronized becomes a problem.


1. What does "invalidation" actually mean?

Cache invalidation simply means making sure an old cached value is no longer used. There are several ways to do it: delete the cached value and let the next request reload it, update the cached value directly, wait for the TTL to expire it, or invalidate it in response to a published event. Each has trade-offs. Let's look at them.


2. Strategy #1 — TTL

The simplest solution is to not worry about invalidation at all: just let the cache expire.

product:123
TTL = 10 minutes
Enter fullscreen mode Exit fullscreen mode

After ten minutes, the entry expires and the next request becomes a miss that repopulates it from the database. TTL is popular because it's simple — you don't need to know exactly when the database changes. The cache eventually becomes invalid on its own.


3. TTL is a trade-off

We covered this in Part 2, so briefly: a short TTL gives fresher data at the cost of more misses and more database traffic. A long TTL gives better cache efficiency at the cost of potentially stale data.

So the useful question was never "what TTL should I use?" It's "how stale can this data safely be?" — a much more concrete engineering question, and one we'll come back to near the end of this post.


4. Strategy #2 — explicit invalidation

Instead of waiting for the cache to expire, we can invalidate it the moment the data changes:

UPDATE database
       ↓
DELETE product:123 from cache
Enter fullscreen mode Exit fullscreen mode

The next request becomes a cache miss, reads $899 from the database, and repopulates the cache.

Why delete rather than update in place? Sometimes updating is fine, but deleting is often simpler — the next read just goes and gets the authoritative value, so fewer things need to stay in sync. That gives us Write → Database → Delete cache rather than Write → Database → Update cache.


5. The dual-write problem

Here's the catch: you're now writing to two systems, and either one can fail independently of the other.

The dual-write problem: updating the database then deleting the cache leaves the cache serving a stale value if the delete fails, while updating the cache then the database leaves the cache ahead of the source of truth if the database write fails

If you update the database first and the cache delete fails, the cache keeps serving $999 until the TTL eventually clears it — the database is correct, the cache is just temporarily behind. If you update the cache first and the database write fails, the cache is now ahead of the source of truth, showing a value the database never actually confirmed.

Whenever you're writing to two systems independently, you need to think through what happens if one succeeds and the other doesn't.


6. Strategy #3 — update the cache directly

Another approach updates both values in the same operation: Database: $999 → $899 and Cache: $999 → $899, avoiding the temporary miss entirely. The next request immediately sees $899.

The cost is coordination. The application has to make sure the cache update actually happens — and if it fails, do you retry, log it, send an event, or fall back to just deleting the entry instead? The more sophisticated the strategy, the more failure scenarios you need to have an answer for.


7. Strategy #4 — event-driven invalidation

Instead of every component directly coordinating the cache update, the system can publish an event and let interested consumers react to it.

Event-driven invalidation with TTL as a safety net: the product service publishes a ProductPriceChanged event to a bus that fans out to cache invalidation, search index updates, and analytics, and if the event is lost the TTL still expires the stale entry eventually

The product service doesn't need to know every consumer that cares about the change — cache invalidation, search indexing, and analytics can all react independently. That's a more loosely coupled architecture. The price is another distributed component: the event system itself, and events can fail, be delayed, be duplicated, or arrive out of order.

If an invalidation event is lost, the cache consumer never fires and the stale entry just stays — which is exactly why production systems often combine event-driven invalidation with a reasonable TTL. The event gives fast invalidation; the TTL is the safety net that cleans up if the event never arrives.


8. Cache-Aside + invalidation

Connecting this back to Part 4: if you're using Cache-Aside, the read path checks the cache and falls back to the database on a miss, and the write path is just:

updateProduct(product);
cache.delete("product:" + product.getId());
Enter fullscreen mode Exit fullscreen mode

Update the database, delete the cache entry, let the next read rebuild it. This is one of the simplest practical caching designs, and it's often easier to reason about than trying to manually keep every cached representation in sync.


9. But what if multiple caches exist?

Recall Part 3: a request can pass through a browser cache, a CDN, a local application cache, and a distributed cache before it ever reaches the database. Now the product price changes — which of those do you invalidate?

It's no longer DELETE key from Redis. It's an architectural question, and it's one reason systems try to avoid caching the same mutable data at too many layers: every additional copy is another invalidation problem.


10. The cache invalidation race condition

Here's a subtler problem. Suppose two requests happen at almost the same time: Request A is updating the price, and Request B is reading it.

How a race condition re-stales the cache: Request B reads the old price before Request A's update lands, Request A updates the database and deletes the already-empty cache, and then Request B's delayed write puts the stale value back into the cache, wrong until the TTL clears it

The delete itself wasn't wrong — it happened exactly when it should have. The problem is timing: Request B had already read the old value before Request A's write landed, and Request B's write to the cache arrived after the delete. The stale value gets written back in, and now the cache is wrong until the TTL expires it.

This is why cache consistency isn't just a matter of adding a cache.delete() call. Concurrency and ordering matter too.


11. Invalidation vs. refresh

These are related but different. Invalidation removes the value — DELETE, then let the normal read path rebuild it. Refresh replaces the old value directly with the new one, avoiding a cache miss but requiring the system to know how to fetch and apply the latest value. Invalidation is simpler; refresh can be faster for the next reader. Which one fits depends on how expensive a miss is for that particular key.


12. What about stale-while-revalidate?

A cleverer option: if the cached value is technically expired, serve it anyway while refreshing it in the background, rather than making the user wait on a database call.

User → Cache → serve the slightly stale value
                       ↓
                background refresh → Database
Enter fullscreen mode Exit fullscreen mode

This can give excellent user-facing latency. But it's deliberately serving stale data on purpose, so it only works where the business can tolerate that — which brings us to the real question underneath all of this.


13. The real question: how fresh is fresh enough?

Consider three systems. On a blog, if a reader sees a just-published article's old version for 30 seconds, that's almost certainly fine. On an e-commerce site, a customer seeing a stale price for 30 seconds might be acceptable, or might be a real business problem, depending on context. On a financial system, a user seeing yesterday's account balance is unacceptable for most operations.

The same caching technique cannot be blindly applied everywhere. This is the question every strategy in this post is really answering, just from a different angle.


14. A practical starting point

For many backend systems, a reasonable default looks like this:

WRITE                          READ
  ↓                              ↓
Database                      Cache
  ↓                          /      \
Invalidate cache          HIT        MISS
  ↓                         ↓          ↓
Set a TTL too            Return    Database
                                       ↓
                                 Populate cache
Enter fullscreen mode Exit fullscreen mode

Fast reads, relatively simple invalidation, TTL as a safety mechanism, and the database as the one source of truth. It isn't perfect — but it's understandable, and understandability is a real production advantage.


15. Six rules worth keeping in mind

  • Know your source of truth. Usually the database is authoritative and the cache is a temporary copy — don't accidentally let the cache become the source of truth unless your architecture explicitly calls for it.
  • Define acceptable staleness deliberately. Don't pick a TTL at random; ask how long this specific data can safely be wrong.
  • Prefer simple invalidation when it's enough. UPDATE DB, DELETE cache beats a complicated synchronization mechanism more often than it seems like it should.
  • Have a safety net. TTL keeps protecting you even when explicit invalidation fails.
  • Think about failures explicitly. What happens if the database succeeds but the cache invalidation fails? What happens if the cache is unavailable altogether?
  • Don't create unnecessary copies. Every additional caching layer is another invalidation problem, not just another performance win.

The bigger lesson

Caching is usually introduced as a performance optimization. But the moment you start caching mutable data, you're actually managing multiple copies of reality — which means thinking about performance, freshness, consistency, failure handling, and operational complexity all at once.

The fastest cache isn't necessarily the best one. A cache that's always perfectly fresh may be too expensive to run. A cache that's extremely cheap may serve data that's too stale to trust. The engineering work is finding the right balance for each piece of data — not once, but deliberately, the way we did with the product example back in Part 4.


What's next

We've now covered why caching exists, how it works, where it lives, the patterns for reading and writing through it, and how to keep it from lying to you.

Next, we'll get more concrete. There are two names you're almost guaranteed to run into when working with distributed caching: Redis and Memcached. But the interesting question was never "which one is better?" — it's "what kind of caching problem are we actually trying to solve?"


Has a cache invalidation bug ever made it to production on something you built? The delete-vs-update-order kind of bug is usually invisible until exactly the wrong moment — curious what triggered yours.

Top comments (0)