Redis Cache Invalidation: Four Patterns From Production
Cache invalidation is famously "one of the two hard things in CS." True — but the hard part isn't the magic, it's choosing the right pattern for your shape of system. Four field-tested patterns from iGaming production.
Adding a cache is easy. Removing entries from it correctly is hard. Every team that ships "we added Redis and got 100× faster" eventually ships a follow-up bug: "users are seeing stale data." The cache isn't the problem — the missing invalidation strategy is.
Four patterns I use in production. Each is right for a specific scenario; none is universal.
1. TTL — and only TTL
The simplest pattern: write to cache, let it expire after X seconds. Re-read from DB when the next request comes in.
When it fits: the data tolerates "slightly stale." Leaderboards, popular content lists, user profile summaries.
Example: we cache the leaderboard for 30 seconds. Even if a score changes, the rest of the world sees the new value within 30s. For 10,000+ concurrent users this cut DB load by 95%.
Trap: stretching the TTL because "it'll expire eventually." A 10-minute TTL is 10 minutes of potentially showing wrong data. The right question is: how many seconds of staleness can I tolerate? That's your TTL.
2. Write-through invalidation
On write, update the cache too. DB write + cache update, hand-in-hand.
When it fits: you need the cache to reflect the freshest write — bank balances, account state, anything financial.
Example: user balance. After a deposit/withdrawal, write to DB and immediately update the Redis entry. The user checks their balance one second later and sees the right number.
Trap: transaction boundaries. DB write succeeds, cache write fails → inconsistency. Mitigation: delete the cache entry instead of updating it — the next read will reload from DB cleanly.
DB.update(...) # write to DB first
cache.delete(key) # then invalidate, don't set
Deleting is safer than setting under race conditions.
3. Event-driven invalidation
When data changes, publish an event. Caches that depend on that data subscribe and invalidate themselves.
When it fits: multiple caches in different services depend on the same source data.
Example: a product price changes. The price cache, the category cache, the "popular product" cache all need to invalidate. Publish a product.price.changed event; each cache key gets deleted by its own listener.
Trap: the event being lost. If the broker drops the message, the cache stays stale forever. Mitigation: event-driven plus a TTL safety net. Both at once.
4. Versioned key pattern
Embed a version number in the cache key. When data changes, bump the version — old keys age out via TTL on their own.
cache.get(`user:profile:${userId}:v${currentVersion}`)
Bumping the version is one DB field update (or a single Redis counter).
When it fits: wide invalidation — "blow away all caches related to this user."
Example: a user's subscription status changes; 20 different caches depended on their permissions. Instead of finding and deleting all 20, just bump user:${userId}:permissions:v${++version}. The next read populates fresh; old keys die naturally.
Trap: dead key buildup in Redis memory. Always set TTL.
Cache stampede — the universal enemy
The instant a hot key expires, a thousand parallel requests hit the DB at the same time. Recurring nightmare.
Solution 1: stale-while-revalidate. When the TTL expires, return the stale value immediately and refresh in the background. The user sees old data, but they see it fast.
Solution 2: probabilistic early refresh. During the last 10% of the TTL, each request has a small probability of refreshing the cache. Refresh load spreads over time.
Solution 3: lock-based. On a miss, the first request takes a lock, fetches from DB, populates the cache. Other requests wait on the lock. No stampede, but artificial latency.
I use 1 and 2 in production. 3 is theoretically clean but operationally not worth it.
How I pick
My decision tree:
- Can the data be slightly stale? → TTL only
- Need consistency on write? → Write-through, delete pattern
- Multiple caches consume the same data? → Event-driven
- Need wide blast invalidation? → Versioned key
Often you stack two: TTL + write-through, belt and braces.
Closing
Cache invalidation isn't hard — asking the right question is hard. Once you can answer "how stale is acceptable?" the right pattern usually picks itself.
Adding a cache is easy. Building a real caching strategy means understanding how your system actually behaves. The half-hour you think you saved adding it might be the prep for a two-day debug session a quarter later.
Long-form notes on distributed systems, production stability, AI-assisted shipping. No spam, easy to unsubscribe.
Comments
No comments yet. Be the first.