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.
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
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
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.
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.
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());
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.
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
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
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 cachebeats 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)