Cache Invalidation Strategies with Redis
There's an old joke that there are only two hard problems in computer science: cache invalidation and naming things. The joke endures because it's true. Caching is easy to add and hard to keep correct, and nearly every caching bug is really an invalidation bug: the cache is serving data that no longer matches the database. This article covers the strategies for keeping cached data correct, from the simple TTL to explicit and event-driven invalidation, and how to reason about which to use.
This is part 13 of the Redis Masterclass, following refresh-ahead.
Why invalidation is hard
The difficulty is that the cache and the database are two copies of the same data, and the moment the database changes, the cache is wrong until something fixes it. Every strategy is a different answer to "how does the cache find out the underlying data changed," and each trades freshness, complexity, and correctness differently. There's no perfect answer, which is the real content of the joke: you're always choosing a tradeoff.
Strategy 1: TTL expiration
The simplest strategy is to not actively invalidate at all, and just let entries expire. You set a TTL, and after it elapses the data reloads fresh. Staleness is bounded by the TTL: with a 60-second TTL, the cache is never more than 60 seconds behind.
SET user:1 "..." EX 60 # accept up to 60s of staleness
This is the right choice for data where some staleness is acceptable, which is more data than people assume. A product listing 30 seconds out of date, a dashboard a minute stale, a config that updates within 5 minutes: for all of these, a TTL alone is enough, and it's wonderfully simple. Choose the TTL by how much staleness the data tolerates. The weakness is that you can't get both perfectly fresh and long-cached: freshness and hit rate pull against each other.
Strategy 2: explicit invalidation on write
When staleness isn't acceptable, invalidate explicitly: whenever you write to the database, delete the corresponding cache key so the next read repopulates it. This is the delete-on-write rule from the cache-aside article:
async function updateUser(id, data) {
await db.update('users', id, data);
await redis.del(`user:${id}`); // invalidate immediately
}
Now the cache is corrected the instant the data changes, not TTL-seconds later. The challenge is completeness: you have to invalidate every cache key affected by a write, and it's easy to miss one. If a user's data appears in user:1, in a users:list cache, and in a search:results cache, updating that user must invalidate all three. Missing one leaves a stale entry. Explicit invalidation is precise but demands that you track every place data is cached and invalidate all of them on change.
Strategy 3: tag-based / grouped invalidation
When one change should invalidate many keys, tracking them individually is error-prone. Tag-based invalidation groups related keys so you can invalidate them together. One approach uses a Redis set to track which keys belong to a group:
# when caching, record the key under its tags
SADD tag:user:1 user:1 profile:1 posts:by:1
# to invalidate everything for user 1:
# read the set, delete all those keys, delete the set
Now updating user 1 invalidates the whole group in one operation, without enumerating keys by hand at each write site. This scales the explicit approach to complex caches where a single entity's data is spread across many keys. It adds bookkeeping (maintaining the tag sets) but removes the "did I remember every key" risk.
Strategy 4: event-driven invalidation
In systems with multiple services or replicas, the service that writes may not be the one holding stale cache. Event-driven invalidation broadcasts a change so all interested parties invalidate. Redis pub/sub (covered later) or a change stream carries "user 1 changed" messages, and every service subscribes and clears its relevant cache:
# writer publishes on change
PUBLISH cache:invalidate '{"entity":"user","id":1}'
# every service subscribes and deletes its matching keys
This keeps caches consistent across a distributed system, where a single DEL on one node isn't enough. It's the most powerful and the most complex, needing a messaging mechanism and subscribers everywhere. Reserve it for genuinely distributed caches where local invalidation can't reach all the copies.
Versioned keys: invalidation without deletion
A clever alternative sidesteps invalidation by changing the key. Include a version in the cache key, and bump the version to "invalidate" without deleting anything:
GET user:1:v5 # current version
# on change, increment the version pointer, so reads now use v6
Old versioned keys simply stop being requested and expire on their own TTL, while new reads use the new version. This avoids the race where a delete and a repopulate interleave badly, and it's handy for caches that are expensive to invalidate precisely. The cost is that old versions linger in memory until they expire.
Choosing a strategy
The decision follows the data:
- Staleness tolerable? TTL alone. Simplest, and enough for a lot of data.
- Need immediate freshness, single service? Explicit delete-on-write, plus tag-based grouping when a change touches many keys.
- Distributed cache across services? Event-driven invalidation via pub/sub or a change stream.
- Invalidation races a problem? Versioned keys.
Most real systems combine these: a TTL as a safety backstop on everything (so even a missed invalidation self-corrects eventually), plus explicit invalidation on writes for data that must be fresh. That combination holds up well, because the TTL catches anything the explicit invalidation misses.
Cache invalidation is hard because it's fundamentally about keeping two copies in sync, and every strategy trades freshness against complexity. Start with a TTL, add explicit invalidation where freshness matters, group keys with tags when changes fan out, and reach for event-driven invalidation only in distributed setups. Always keep a TTL underneath as the backstop, because the invalidation you forgot is the bug you'll ship.
Next, we tackle the specific failure that several patterns have hinted at: the cache stampede, when a hot key expires and a herd of requests hammers the database at once.
Key takeaways
- Nearly every caching bug is an invalidation bug: the cache serving data that no longer matches the database.
- TTL expiration bounds staleness with no active work and suffices for any data that tolerates some staleness.
- Explicit delete-on-write gives immediate freshness but requires invalidating every key a change affects.
- Tag-based grouping invalidates many related keys together; event-driven invalidation keeps distributed caches consistent.
- Keep a TTL as a backstop under explicit invalidation, so a missed invalidation still self-corrects.
Frequently asked questions
Why is cache invalidation considered hard?
Because the cache and database are two copies of the same data, and the cache becomes wrong the instant the database changes. Every strategy is a different, imperfect answer to how the cache learns about that change, each trading freshness against complexity.
What's the simplest cache invalidation strategy?
TTL expiration: set a time-to-live and let entries expire and reload. Staleness is bounded by the TTL, and it needs no active invalidation. It's enough whenever the data can tolerate being a little out of date.
How do I invalidate the cache when data changes?
Delete the affected cache keys right after writing to the database (delete-on-write), so the next read repopulates fresh values. The challenge is invalidating every key the change touches; tag-based grouping helps when a change affects many keys.
How do I keep caches consistent across multiple services?
Use event-driven invalidation: broadcast a change message via Redis pub/sub or a change stream, and have each service subscribe and clear its relevant cache. A single local delete can't reach caches held by other services.
Should I use a TTL even with explicit invalidation?
Yes. Keep a TTL as a backstop so that if you ever forget to invalidate a key on some write path, the stale entry still self-corrects when it expires. It turns a permanent bug into a temporary one.

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.