tolgacelik.net
All posts
·3 min read

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.

rediscacheperformancescalingpatterns

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:

  1. Can the data be slightly stale? → TTL only
  2. Need consistency on write? → Write-through, delete pattern
  3. Multiple caches consume the same data? → Event-driven
  4. 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.

ShareLinkedInX
Get the next one in your inbox

Long-form notes on distributed systems, production stability, AI-assisted shipping. No spam, easy to unsubscribe.

Email only. Stored on this server. Never sold or shared.

Comments

No comments yet. Be the first.

Leave a comment

Never shown publicly. Only used if I need to reply.