What is Cache Invalidation?
Cache invalidation is the process of removing or replacing cached data once it no longer matches the source of truth, so the cache only ever serves current values. Without it, a cache quietly returns stale data — and a fast wrong answer is usually worse than a slow correct one.
This page covers the main cache invalidation strategies, how invalidation differs from eviction, why invalidation is genuinely difficult once a cache is distributed across many nodes, and how invalidation works in Valkey and Redis.
Why cache invalidation is considered hard
There is a well-worn joke that the two hard problems in computer science are cache invalidation and naming things. The reason invalidation earns its place is that it is not one problem but a conflict between three goals that cannot all be maximised at once:
- Freshness — cached data should reflect the source as closely as possible.
- Performance — checking the source on every read defeats the point of caching.
- Cost — aggressive invalidation generates traffic, and in a distributed cache that traffic crosses the network.
Every strategy below is a different answer to how much staleness you are willing to tolerate in exchange for fewer reads against the source. There is no configuration that removes the trade-off; there is only choosing where to sit on it, per data type.
A second difficulty is ordering. Once data is cached in several places — a browser, a CDN, an application's local cache, a shared remote cache — a single write has to propagate through all of them, and if the updates land out of order a layer can repopulate itself with the value that was just invalidated.
Cache invalidation strategies
Most systems combine several of these rather than picking one.
Time-based invalidation (TTL)
The simplest and most widely used strategy. Each entry is stored with a time to live (TTL), and expires automatically once it elapses. No coordination is needed and no invalidation message has to be sent, which makes TTL cheap and extremely robust.
The cost is bounded staleness: with a 60-second TTL you accept that data may be up to 60 seconds out of date. That is fine for a product listing and unacceptable for an account balance. Setting TTLs per data type rather than globally is one of the highest-value tuning decisions available.
Event-based (write-through) invalidation
The cache is updated or evicted as part of the write that changed the underlying data. Because invalidation is tied to the event that caused it, staleness is near-zero.
This is the model behind write-through and write-behind caching. The trade-off is coupling: every code path that writes to the database must also invalidate the cache, and a path that forgets creates a stale entry that nothing will ever clear until its TTL expires — which is a strong argument for always setting a TTL as a backstop, even when using event-based invalidation.
Command-based invalidation (purge)
An explicit instruction removes an entry or a set of entries — triggered by an administrator, a deploy, or a user action such as deleting a file. Purges are precise and are the usual escape hatch when something has gone wrong, but they are manual by nature and do not scale as a primary strategy.
Group- or tag-based invalidation
Entries are tagged with a shared label, and invalidating the tag clears everything carrying it. An eCommerce site that updates a product category can invalidate that category's tag rather than enumerating every affected product key.
This solves the dependency problem, where one cached item derives from another. The risk is over-invalidation: a tag drawn too broadly throws away far more cached data than the change required, causing an avoidable burst of cache misses.
Version-based invalidation (key versioning)
Rather than deleting a stale entry, version-based invalidation makes it unreachable by changing the key. A version token is embedded in the cache key itself, and a write increments it:
user:42:profile:v7 # current
user:42:profile:v8 # after a write bumps the version
The next read computes a different key, misses, and repopulates from the source. The v7 entry is never deleted — it is simply orphaned, and expiry or eviction reclaims the memory later.
The variant that earns this strategy its place is generational invalidation, where the version is attached to a whole namespace rather than a single entity:
products:gen12:item:99
products:gen12:list:page1
products:gen12:category:shoes
A single increment of products:gen invalidates every key in that generation at once — atomically, in constant time, with no key enumeration and no SCAN. That makes it the practical way to implement group-based invalidation on a store that has no native tag support, where the alternative is maintaining a set of keys per tag and deleting them one by one. In a cluster the tag approach is worse still, because the tagged keys are scattered across hash slots and cannot be removed in a single multi-key command.
Version-based invalidation also has a property none of the delete-based strategies share: it is structurally immune to the stale-repopulation race described in the next section. Because a slow reader writes its result under the version it read, a value that has since been superseded lands on a key nobody will look up again.
The costs are real and worth stating. Orphaned entries occupy memory until they expire, so a TTL and an eviction policy stop being a backstop and become mandatory. Reading the version costs an extra round trip that cannot be pipelined, because the data key depends on the version's value. And because every read touches the version key, it becomes a hot key on a single node.
| Strategy | Staleness | Coordination cost | Best for |
|---|---|---|---|
| Time-based (TTL) | Bounded by the TTL | None | Most data; always worth setting as a backstop |
| Event-based | Near zero | Every writer must invalidate | Data where correctness matters more than write throughput |
| Command-based | Zero, when it runs | Manual | Deploys, incidents, targeted fixes |
| Group / tag-based | Near zero for the group | Tag bookkeeping | Related entries that must change together |
| Version-based | Near zero | One counter read per cache read | Bulk invalidation; avoiding stale repopulation |
Cache invalidation vs cache eviction vs expiration
These three are routinely conflated, and the confusion matters because it leads people to describe LRU and LFU as invalidation strategies. They are not. LRU, LFU, FIFO and random replacement are eviction policies: they decide what to discard when memory runs out, and they say nothing about whether an entry is still correct.
| Triggered by | Decided by | The removed entry was | |
|---|---|---|---|
| Invalidation | The source data changing | Application logic | Incorrect |
| Expiration | A TTL elapsing | The clock | Possibly still correct |
| Eviction | Memory pressure | The eviction policy | Usually still correct |
The practical consequence is that eviction can undo your invalidation planning without warning. If the cache is full and the policy is allkeys-lru, entries you expected to survive until their TTL will disappear early, and a workload tuned around a 10-minute staleness window may quietly be operating on a much shorter one. Invalidation controls correctness; eviction controls capacity; only expiration sits in both camps.
The dual-write problem: ordering a database write and a cache invalidation
A write that touches both a database and a cache is two operations against two systems with no shared transaction. Whatever order you choose, there is a failure or interleaving that leaves the cache holding a value the database never agreed to.
The most common ordering in cache-aside code is to commit to the database and then delete the cache key. It is the best of the delete-based options, and it is still exposed to a stale-repopulation race:
- A reader misses on
user:42and queries the database, which returns the old value. - A writer commits the new value and deletes
user:42. There is nothing there to delete. - The reader, still finishing its earlier work, writes the old value into
user:42.
The cache now holds a value that no longer exists in the database, and nothing will correct it until the TTL fires. The window is narrow — it requires a reader's database query to straddle the write — but on a hot key under concurrent load, narrow windows are hit routinely.
Two orderings are worse and should be avoided. Writing to the cache before the database means a failed or rolled-back transaction leaves the cache advertising a value that was never committed. Writing to the database and then updating the cache with the new value, rather than deleting it, lets two concurrent writers land their updates in the opposite order to their commits, leaving the older value cached.
Mitigations, in rough order of strength:
- Always set a TTL. It does not prevent the race; it bounds how long the damage lasts. This is the single most valuable habit on this page.
- Delete twice. Deleting before the database write and again a short delay afterwards shrinks the window considerably, at the cost of an extra round trip and a scheduled task. It does not close the window.
- Version the key. The reader's late write lands on the superseded version and is never read, which removes the race rather than shrinking it.
- Derive invalidation from the commit itself. Publishing invalidations from the database's replication stream, or from an outbox, means the invalidation cannot be forgotten by a code path or ordered ahead of the commit.
Invalidation in a distributed cache
Everything above is comparatively simple when there is one cache. It becomes considerably harder in a distributed cache, where many application nodes each hold their own copy of the data.
A write on one node now has to reach every other node holding that entry. The usual mechanism is a publish/subscribe channel: the node that performs the write publishes an invalidation message, and every subscriber drops its local copy. That introduces two problems that a single-process cache never has.
Invalidation granularity. If the invalidation message only names the object rather than the entry that changed, subscribers cannot evict precisely — they have to clear the whole structure. For a map holding thousands of entries, one small write then destroys the entire local cache, and every node takes a wave of misses rebuilding it — a thundering herd triggered by invalidation rather than expiry.
Missed invalidations. If a node briefly loses its connection, it stops receiving invalidation messages while continuing to serve reads from a local cache that is now silently wrong. What the node does on reconnect — clear everything, or replay what it missed — is a real design decision, and one many caching layers simply do not address.
Cache invalidation in Valkey and Redis
Valkey and Redis handle expiry-based invalidation natively: set a TTL on a key and the server removes it. For anything more precise, invalidation has to be coordinated by the client.
Both support client-side caching via client tracking over the RESP3 protocol, where the server notifies clients when a tracked key changes. The limitation is granularity — for hash-based structures the invalidation message identifies only the object name, not the changed field, so the client can only discard the whole thing.
Redisson addresses this with its own local cache implementation that invalidates per entry rather than per object, and exposes the propagation behaviour directly:
LocalCachedMapOptions options = LocalCachedMapOptions.defaults()
// INVALIDATE - drop the entry on every other node when it changes
// UPDATE - push the new value out instead, avoiding the refetch
// NONE - no propagation; accept staleness
.syncStrategy(SyncStrategy.INVALIDATE)
// what to do about invalidations missed while disconnected
// CLEAR - discard the whole local cache on reconnect
// LOAD - replay the invalidation log if the gap was under 10 minutes
// NONE - no reconnection handling
.reconnectionStrategy(ReconnectionStrategy.LOAD)
.evictionPolicy(EvictionPolicy.LRU)
.cacheSize(10_000)
.timeToLive(Duration.ofMinutes(10));
RLocalCachedMap<String, Integer> map = redisson.getLocalCachedMap("stock", options);
The two settings map directly onto the problems above. syncStrategy decides the freshness-versus-traffic trade-off: INVALIDATE is cheaper to send but forces a refetch on the next read, while UPDATE avoids that miss at the cost of pushing the value to every node. reconnectionStrategy decides what a node does about the invalidations it missed during a network gap — LOAD replays them where possible, CLEAR takes the safe option and discards everything.
Note that timeToLive is still set. Event-based invalidation and a TTL are complements, not alternatives: the TTL is what limits the damage when an invalidation is missed.
Generational invalidation with an atomic counter
Neither Valkey nor Redis has native cache tags, so the version-based approach described above is usually the cleanest way to invalidate a group of keys. A shared counter supplies the generation:
RAtomicLong generation = redisson.getAtomicLong("products:gen");
// Read - the generation forms part of the key
long gen = generation.get();
RBucket<Product> bucket = redisson.getBucket("products:" + gen + ":item:" + id);
Product product = bucket.get();
if (product == null) {
product = repository.findById(id);
// the TTL is what reclaims superseded generations
bucket.set(product, Duration.ofHours(1));
}
// Invalidate every cached product, whatever the key count, in one operation
generation.incrementAndGet();
The obvious weakness is that every read now touches products:gen, which concentrates traffic on one key and, in a cluster, one node. Holding the generation in a local cache with UPDATE propagation removes the round trip without reintroducing staleness, because a bump is pushed to every node rather than forcing them all to miss at once:
RLocalCachedMap<String, Long> generations = redisson.getLocalCachedMap("generations",
LocalCachedMapOptions.<String, Long>defaults()
.syncStrategy(SyncStrategy.UPDATE)
.timeToLive(Duration.ofMinutes(5)));
Bumping a broad generation invalidates a large number of entries simultaneously, which is a deliberately triggered thundering herd. Keep generations scoped to data that genuinely changes together.
Keyspace notifications
Valkey and Redis can publish an event whenever a key is modified, expires or is evicted, which allows a cache in another process — or another system entirely — to invalidate in response. The feature is off by default and enabled through notify-keyspace-events:
notify-keyspace-events KEA # all event classes; narrow this in production
It is worth understanding the limits before building on it. Events are delivered over ordinary publish/subscribe, which is fire-and-forget: a subscriber that is disconnected when an event fires never learns about it, and there is no replay. In a cluster, events are published by the node that owns the key, so a subscriber has to connect to every node rather than one. And generating events costs CPU proportional to write throughput, which is why enabling the full KEA set on a busy instance is a poor default.
Keyspace notifications are best treated as a way to propagate invalidation to systems that could not otherwise know about a change, rather than as the primary mechanism for keeping application caches correct. For that, client tracking or a library-level local cache gives stronger guarantees.
See the cache API documentation for the equivalent settings in the Spring, Hibernate, JCache and MyBatis integrations, and Redis client-side caching for how the underlying tracking mechanism works.
Frequently asked questions
What is cache invalidation?
Cache invalidation is the process of removing or replacing cached data once it no longer matches the source of truth, so that the cache only serves current values. Without it a cache returns stale results indefinitely.
What are the main cache invalidation strategies?
Time-based invalidation using a TTL, event-based invalidation triggered by the write that changed the data, command-based invalidation through an explicit purge, group or tag-based invalidation that clears a set of related entries together, and version-based invalidation that changes the cache key rather than deleting the entry. Most systems combine several.
Why is cache invalidation considered hard?
Because freshness, performance and cost cannot all be maximised at once. Checking the source on every read defeats the purpose of caching, while aggressive invalidation generates network traffic. In a distributed cache, updates must also propagate to every node in the right order.
What is the difference between cache invalidation and cache eviction?
Invalidation removes an entry because it is no longer correct, and is triggered by a change in the source data. Eviction removes an entry because the cache needs the space, using a policy such as LRU or LFU. An evicted entry may still have been perfectly valid, which is why LRU and LFU are eviction policies rather than invalidation strategies.
What is version-based cache invalidation?
Version-based invalidation embeds a version token in the cache key and increments it on write, so stale entries become unreachable rather than being deleted. Attaching the version to a namespace instead of a single entity invalidates every key in that namespace with one atomic increment, and superseded entries are reclaimed by their TTL.
How do you invalidate many cache keys at once in Redis or Valkey?
Neither has native cache tags, and scanning for matching keys is slow and unsafe on a large keyspace. The usual approach is generational versioning: put a shared counter in the key prefix and increment it, which makes every key carrying the old generation unreachable in a single constant-time operation.
How do you invalidate a cache in Redis or Valkey?
Set a TTL and let the server expire the key, delete the key explicitly on write, bump a version embedded in the key, or use client tracking over RESP3 so the server notifies clients when a key changes. Client libraries such as Redisson add per-entry invalidation across application nodes.