
A user updates their profile picture, hits save, and the page confirms it worked. They refresh to check. The old picture is still there.
No error. No crash. Nothing in the logs even looks wrong. The write to the database genuinely succeeded; the new picture was sitting right there in the row. But for the next 30 seconds, everyone who loaded that profile, including the user who’d just changed it, saw the old one.
That’s a scarier kind of bug than a slow query or a hanging request, because nothing looks broken. The app is fast. The database is fine. It’s just quietly, confidently wrong.
The wrong mental model
Most people treat caching as the hard part: pick Redis, wrap your read in a try cache, fall back to database block, set a TTL, and ship it. That's the easy 80%.
The actual hard part is knowing exactly when a cached value stops being true and clearing it out before anyone reads it. There’s an old line in computer science about there only being two genuinely hard problems: cache invalidation and naming things. It gets repeated as a joke, but it’s not really a joke—invalidation is hard because it requires you to know, with certainty, every single place in your codebase that can change the underlying data and make sure every one of them also clears the cache. Miss one write path, anywhere, and you’ve got a bug that only shows up sometimes, which is the worst kind to track down.
**
The three ways people update a cache
**
There are really only three patterns here, and knowing which one you’re using changes what can go wrong.
Cache-aside (also called lazy loading) is the one almost everyone starts with. Your app checks the cache first. On a miss, it reads from the database, then writes that value into the cache for next time. Simple, and it is the most common source of the bug in this article, because nothing automatically tells the cache when the underlying row changes.
Write-through writes to the cache and the database at the same time, as one operation. Safer—the cache is never out of sync with the database for longer than that single write takes. The trade-off is every write is now doing two things instead of one, so writes get a little slower.
Write-behind writes to the cache immediately and queues the database update to happen shortly after, asynchronously. Reads and writes both feel instant. The risk is real, though: if your app crashes after the cache write but before the queued database write completes, that update is gone, and the cache and database can end up telling two different stories.
Most of what follows assumes cache-aside, because it’s what most teams actually use—and it’s the one where you, the developer, are personally responsible for remembering to invalidate.
What actually goes stale, and why TTL doesn’t save you
The comforting assumption is, "I set a 5-minute TTL, so worst case, data is 5 minutes old.” That’s true only for a value that never changes during those 5 minutes. It falls apart the moment a write happens in the middle of that window. TTL protects you against forgetting to invalidate, eventually. It does nothing about the gap between a write happening and the next read after it—and that gap is exactly where the user saw their old profile picture, because 30 seconds was well within a much longer TTL.
The classic bug, reproduced concretely

Walk through it as a timeline. Someone changes their profile picture. That write lands in the database successfully. But the code path that handles the update never touches the cache; it only writes to Postgres (or MySQL, or whatever’s underneath) and calls it done. The cache entry for that user, written the last time anyone viewed their profile, just sits there, unaware anything changed. Every read for the rest of its TTL keeps serving that same stale value, confidently, with no error anywhere in the chain.
This is the single most common shape this bug takes: a write path that updates the source of truth but forgets the cache exists at all.
Race conditions during invalidation
There’s a sneakier version of this bug that happens even when someone did remember to invalidate. Two requests interleave:
Request A checks the cache for a user’s profile—it's a miss, so A starts reading from the database. Before A finishes, request B comes in, updates that same user’s profile in the database, and correctly evicts the cache entry. Then A finishes its database read—with the old data, because it started before B’s write happened—and writes that stale value back into the now-empty cache.
The cache was invalidated correctly. It got repopulated with stale data anyway, by a read that was already in flight when the write happened. This is why “just add" @CacheEvictisn't a complete answer under real concurrent load—the timing of reads and writes matters, not just whether invalidation code exists.
**
Why cache invalidation is genuinely hard, not just annoying
**
This is worth tying back to how Redis actually works under the hood: being fast was never the hard part. Redis keeps everything in RAM and processes commands on a single thread, making it fast by design—that part’s basically solved for you. What’s not solved for you is knowing every code path in your application that can change the data a cache entry represents and keeping every one of them honest about clearing that entry. A cache doesn’t know when it’s wrong. Something else has to tell it, every single time, from every single place that could make it wrong.
**
Patterns that actually help
**
Keep a short TTL even when you have explicit invalidation. Treat it as a safety net for the invalidation call you’ll eventually forget to add somewhere, not as your primary strategy.
Version your cache keys instead of trying to delete every related key individually. If a product’s price changes, and ten different cache keys reference that product in different ways, bump a version number tied to the product instead of hunting down all ten. Old versions simply age out.
Use event-driven invalidation across services. In a multi-service setup, the service that owns the write often isn’t the one holding the cache. Publishing an event (“user 123 updated”) that any interested service can react to scales a lot better than trying to remember every downstream cache by hand.
How to detect stale cache bugs in production
The quiet ones are the hardest to catch precisely because nothing errors. Two things that actually help:
Log cache hit or miss alongside the row’s updated_at timestamp. If a cache hit is being served for a row that updated_at is newer than when that cache entry was written, that mismatch is your stale-read bug, made visible instead of silent.
Spot-check critical reads against the database occasionally, especially right after a known write-heavy action, rather than assuming the cache is correct just because it’s returning fast, confident-looking data.
Top comments (0)