Cache Invalidation Without Losing Your Mind
16 Jul 2026There are two ways a cache goes wrong: it serves stale data, or it stampedes your database when it expires. Every invalidation strategy is a tradeoff between those two failure modes. Here are the three that actually get used in production, and when to reach for each.
TTL: expire and forget
Set a time-to-live on every key and let it expire. No invalidation logic, no coordination between services. This is the default for a reason — it’s the least code you can write and still call it caching.
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300)
The failure mode is staleness. Change a user’s name and the old value serves for up to 5 minutes. For most read-heavy data — product listings, user profiles, dashboard aggregates — that’s fine. Nobody notices a 5-minute lag on a bio field.
The other failure mode is the thundering herd: when a hot key expires, every concurrent request misses the cache at once and hits the database simultaneously. Fix it with jitter on the TTL (300 + Math.random() * 60) so keys don’t all expire in lockstep, or with a lock so only one request repopulates the cache while others wait.
Tag-based invalidation: explicit and precise
Instead of waiting for expiry, you delete the cache entry the moment the underlying data changes. Track which cache keys depend on which data, then bust them on write:
await redis.sadd(`tag:product:${productId}`, cacheKey)
// on update:
const keys = await redis.smembers(`tag:product:${productId}`)
await redis.del(...keys)
This gives you correctness — no stale reads, ever — at the cost of bookkeeping. You now have to remember to invalidate on every write path, including the one some contractor added six months ago that updates the database directly. Miss one and you have a bug that’s invisible until someone complains about seeing old data, which is the worst kind of bug to debug because the cache looks like it’s working.
Use tags when staleness is a real cost: pricing, inventory counts, anything where “wrong for 5 minutes” loses money or trust.
Versioned keys: invalidate by not looking
Instead of deleting anything, you change the key. Bump a version number when the underlying data changes, and bake it into the cache key:
const version = await redis.get(`version:product:${id}`) || 0
const cacheKey = `product:${id}:v${version}`
Old entries just age out via TTL or memory pressure — nobody ever reads them again because nobody asks for that key. This sidesteps the thundering herd problem entirely, since old and new versions can coexist, and it sidesteps the “did I forget to invalidate” problem, since a write only needs to bump one counter. The cost is memory: stale versions sit in the cache until evicted.
Which one
| Strategy | Correctness | Complexity | Stampede risk |
|---|---|---|---|
| TTL | Eventually consistent | Lowest | High without jitter |
| Tags | Strong | Highest | Low |
| Versioned keys | Strong | Medium | None |
Start with TTL plus jitter. It’s one line and it’s correct enough for almost everything you’ll cache in the first year of a product. Move to versioned keys for anything where staleness actually causes support tickets — pricing, permissions, inventory. Save tag-based invalidation for when you have a genuinely complex dependency graph (a change to one entity needs to bust caches on three unrelated pages) and you’ve already burned time chasing stale-read bugs.
The gotcha that gets everyone at least once: caching a 404 or an empty result with the same TTL as a real value. A typo’d product ID gets cached as “not found” for 5 minutes, and now your own test traffic looks like a bug report. Cache negative results with a much shorter TTL, or don’t cache them at all.