Cache Invalidation Strategies Compared
AI generated
SET
TTL
Redis · Caching · Consistency · Backend
Cache Invalidation Strategies Compared
from TTL to tag-based invalidation

Cache invalidation rightfully counts as one of the two hard problems in computer science, because every strategy forces a trade-off between consistency, implementation effort and performance. Redis offers TTL-based, explicit and tag-based invalidation, and this article breaks down which strategy actually fits which use case.

18 min read TTL · Tags · Thundering Herd · Cache-Aside Redis 6.x · 7.x

1. The two hard problems in computer science

The quote "there are only two hard things in computer science: cache invalidation and naming things" captures a real truth, because cache invalidation is not a technical problem, it is a semantic one. A cache stores a copy of data that can change at the source at any time, and the central question is: how does the cache know its copy is stale, without querying the source itself on every request, which would make the cache pointless?

Every answer to that question is a compromise. TTL-based cache invalidation accepts temporary inconsistency in favor of simplicity. Explicit invalidation on write aims for near-instant consistency, but requires every write path to know the affected cache entries. Tag-based invalidation solves the problem of scattered dependencies, but introduces additional index structures. Redis provides the necessary primitives for all three approaches, but the choice of strategy remains an architecture decision that Redis itself cannot make for you.

This article compares the three main strategies for cache invalidation with Redis in detail, covers the thundering herd problem when many clients hit a cache miss simultaneously, and ends with practical decision guidance for choosing the right strategy for a given use case.

2. TTL-based invalidation

The simplest form of cache invalidation sets a fixed lifetime when writing a cache entry, after which Redis automatically removes it. The next read then produces a cache miss, loads the current data from the source, and writes it back into the cache with a fresh TTL. This approach requires no coordination whatsoever between read and write paths, making it by far the simplest form of invalidation, which explains why TTL is by far the most common strategy in practice.

The downside is guaranteed temporary inconsistency: between a data change at the source and the next TTL expiry, the cache serves stale data, in the worst case for the entire TTL duration. For data with low change frequency or low consistency requirements, for example a product catalog that changes a few times a day, a TTL of a few minutes is usually entirely sufficient. For data with high consistency requirements, for example an account balance, plain TTL-based cache invalidation is unsuitable and must be supplemented with explicit invalidation.


# TTL-based caching: simple, no write-path coordination needed
redis-cli> SET product:4711 '{"name":"Widget","price":29.99}' EX 300
OK

# After 5 minutes the key disappears automatically
redis-cli> TTL product:4711
(integer) 287

# Cache miss after expiry triggers a fresh read from the source
redis-cli> GET product:4711
(nil)

3. Explicit invalidation on write

With explicit cache invalidation, the write path itself deletes or updates the affected cache entry as soon as the underlying data changes. In the cache-aside pattern, this typically happens via DEL right after a successful write to the primary data source: the application updates the database, then deletes the corresponding Redis key, and the next read populates the cache with fresh data. This strategy delivers near-instant consistency, because the only delay is the short span between the database write and the cache deletion.

The challenge is that every code path that changes data must know about and correctly invalidate the affected cache keys. For a single entity with a single cache key this is trivial, but for complex data models with several derived or aggregated cache entries it quickly becomes hard to track, since changing one record can theoretically invalidate dozens of cache entries, such as list views, aggregations and search results. If even one affected key is forgotten, a silent consistency bug appears that is often only discovered through user complaints.


<?php
declare(strict_types=1);

final class ProductRepository
{
    public function __construct(
        private readonly \PDO $db,
        private readonly \Redis $redis
    ) {
    }

    /**
     * Updates a product and explicitly invalidates the affected
     * cache entries: the single product and any listing pages.
     */
    public function update(int $productId, array $data): void
    {
        $stmt = $this->db->prepare('UPDATE products SET name = :name, price = :price WHERE id = :id');
        $stmt->execute(['name' => $data['name'], 'price' => $data['price'], 'id' => $productId]);

        // Explicit invalidation on write: delete the single entity cache
        $this->redis->del("product:{$productId}");

        // Also invalidate list caches that embed this product's data
        $this->redis->del('product-list:featured');
        $this->redis->del("product-list:category:{$data['category_id']}");
    }
}

4. Tag-based invalidation

Tag-based cache invalidation solves exactly the problem of scattered dependencies that makes explicit invalidation so error-prone. Every cache entry is linked to one or more tags on write, for example product:4711 and category:22, by maintaining the tag-to-key mapping in a Redis set: SADD tag:category:22 product-list:featured product:4711. When a record changes, the application only needs to know the affected tag, not every individual derived cache key.

Invalidation itself reads all keys linked to the tag via SMEMBERS and deletes them in a batch, usually via a Lua script for atomicity. This approach significantly reduces the cognitive load of writing invalidation logic, because developers only need to know which tags a new cache entry touches, not which other cache entries might depend on it later. The price is extra memory usage for the tag sets and one extra write per cache entry, which for most applications is a good trade for substantially lower risk of forgotten invalidations.


-- invalidate_tag.lua
-- Deletes every cache key associated with a given tag
-- KEYS[1] = tag set key, e.g. "tag:category:22"
local keys = redis.call('SMEMBERS', KEYS[1])
for _, key in ipairs(keys) do
  redis.call('DEL', key)
end
redis.call('DEL', KEYS[1])
return #keys
Strategy Consistency Implementation effort Best fit
TTL-based Delayed until TTL expires Minimal Data with low consistency requirements
Explicit on write Near-instant Medium, per write path Single entities, clear dependencies
Tag-based Near-instant Higher, additional structure Complex, branching dependencies

5. Stale-while-revalidate and thundering herd

An often overlooked problem with every cache invalidation strategy is the thundering herd effect: when a high-traffic cache entry expires or gets explicitly invalidated, hundreds of requests hit a cache miss at the same time and all trigger the same expensive computation or database access in parallel, instead of only one request reloading the data. This can briefly overload the source, right at the moment when the cache was supposed to relieve it.

The solution is a lock around rebuilding the cache entry: the first request after a miss acquires a short lock via SET NX, loads the data, writes it into the cache and releases the lock, while all other requests either wait briefly and read again, or serve the last known, now expired value as a stale-while-revalidate response while a rebuild happens in the background. The latter keeps serving users a response throughout, even if briefly stale, instead of making them wait for an expensive recomputation, and is the more robust choice for cache invalidation under heavy load.


<?php
declare(strict_types=1);

/**
 * Prevents a thundering herd on cache miss by acquiring a short lock
 * before recomputing an expensive value, falling back to a stale
 * cached value for other concurrent callers while it rebuilds.
 */
function getWithHerdProtection(\Redis $redis, string $key, callable $rebuild, int $ttl): mixed
{
    $cached = $redis->get($key);
    if ($cached !== false) {
        return json_decode($cached, true);
    }

    $lockKey = "lock:{$key}";
    if ($redis->set($lockKey, '1', ['NX', 'EX' => 10])) {
        $fresh = $rebuild();
        $redis->setex($key, $ttl, json_encode($fresh));
        $redis->del($lockKey);
        return $fresh;
    }

    // Another process is already rebuilding; serve stale value if any
    $stale = $redis->get("{$key}:stale");
    return $stale !== false ? json_decode($stale, true) : $rebuild();
}

6. Consistency models: cache-aside versus write-behind

In the cache-aside pattern, by far the most common approach, the application reads from the cache first, loads from the source on a miss, and writes the result back into the cache. Writes go directly to the source, followed by explicit cache invalidation. This pattern is easy to understand and debug, because cache and source have clearly separated responsibilities, and a cache outage does not block the application, it merely slows it down.

Write-behind caching reverses the order: writes go into the cache first, which is asynchronously and lazily replicated into the primary source. This significantly reduces write latency for the application, since the primary database is no longer on the critical path of every write, but it creates a time window in which the cache is the only up-to-date copy of the data. If the cache fails during that window, data is irrecoverably lost, which is why write-behind is only justifiable for business critical data with additional persistence safeguards, such as Redis AOF.

7. Strategies compared directly

The choice between the three main strategies for cache invalidation depends less on technical superiority than on the actual requirements of the use case. TTL-based invalidation wins on simplicity and fits anything where brief inconsistency causes no real harm. Explicit invalidation wins on consistency for single entities with clearly known dependencies. Tag-based invalidation wins for complex, frequently changing dependency graphs, where explicit invalidation would be too error-prone.

In practice, most production systems combine all three approaches: a generous TTL as a safety net against forgotten invalidations, explicit invalidation for the most common and critical write paths, and tag-based invalidation for complex aggregations and list views. This combination of cache invalidation strategies is more robust than any single strategy alone, because a failure in one layer is cushioned by the others.

8. Monitoring and debugging invalidation bugs

Broken cache invalidation rarely shows up as an obvious error, it usually shows up as a hard to reproduce symptom: a user sees stale data but cannot reproduce the issue on the next attempt, because the cache entry has since expired. For effective debugging, every cache write and every invalidation should be logged, ideally with timestamp, affected key and triggering event, so it can be reconstructed afterward when an entry was last updated.

A simple but effective monitoring pattern is a cache hit ratio dashboard per cache namespace: if the hit ratio for a specific key namespace suddenly drops sharply, that indicates overly aggressive invalidation. If the hit ratio stays consistently high even though users report stale data, that indicates missing invalidation somewhere in the code. Redis itself exposes the metrics keyspace_hits and keyspace_misses via INFO stats, from which the hit ratio can be computed directly, with no additional instrumentation needed in the application.


# Computing cache hit ratio per namespace from Redis stats
redis-cli> INFO stats | grep keyspace
keyspace_hits:8241902
keyspace_misses:193044

# hit_ratio = hits / (hits + misses) = 0.977 -> 97.7%
# A sudden drop for one namespace signals over-aggressive invalidation

redis-cli> MONITOR | grep "product:4711"
# Live stream of every read/write/delete touching this key

9. Practical decision: which strategy when

For the practical decision, a simple guiding question helps: how costly is a briefly stale response compared to a missing response or an overloaded source? For a product catalog, a five minute old price display is rarely critical, TTL-based cache invalidation is enough. For a shopping cart or an account balance, any delay is potentially a trust issue or even a legal one, explicit invalidation is mandatory. For a search results page aggregated from many individual products, tag-based invalidation is almost always the more maintainable solution.

A common mistake is committing to a single strategy for the entire system instead of deliberately deciding per data type. Cache invalidation is not a general-purpose problem with a general-purpose solution, it is a collection of individual decisions that should each follow the actual consistency requirements and change frequency of the affected data.

10. Summary

Cache invalidation with Redis offers three complementary strategies instead of one correct answer: TTL-based for simplicity with tolerable delay, explicit on write for near-instant consistency with clear dependencies, tag-based for maintainable invalidation of complex dependency graphs. Most production systems combine all three, with TTL as a safety net against human error in explicit invalidation logic.

The thundering herd effect on simultaneous cache misses from many clients deserves its own attention regardless of the chosen invalidation strategy, solved through locks or stale-while-revalidate. Monitoring via hit ratio metrics makes cache invalidation bugs visible before users report them, and the choice of strategy should be made per data type, not uniformly for the entire system.

Cache invalidation strategies, the essentials at a glance

TTL-based

Simplest strategy, accepts temporary inconsistency, good for low consistency requirements.

Explicit on write

Near-instant consistency, requires knowledge of all affected cache keys per write path.

Tag-based

Solves scattered dependencies via Redis sets, more maintainable for complex aggregations.

Thundering herd

A rebuild lock or stale-while-revalidate prevents overloading the source on a cache miss.

11. FAQ: Cache Invalidation Strategies

1Why is cache invalidation so hard?
It is a semantic, not a purely technical problem, since the cache must know when its copy is stale.
2When is TTL-based enough?
For low consistency requirements or low change frequency of the data.
3Downside of explicit invalidation?
Every write path must know all affected cache keys, or silent bugs appear.
4How does tag-based invalidation work?
Cache entries are linked to tags in a Redis set and deleted together.
5What is thundering herd?
Many parallel requests trigger the same expensive computation on a cache miss.
6How do I prevent thundering herd?
With a rebuild lock or a stale-while-revalidate response.
7Cache-aside or write-behind?
Cache-aside writes to the source first, write-behind writes to the cache first.
8Multiple strategies at once?
Yes, common in practice: TTL as a safety net plus explicit and tag-based invalidation.
9How do I detect invalidation bugs?
Via a hit ratio dashboard based on Redis INFO stats.
10One strategy for the whole system?
No, the choice should be made per data type based on the consistency requirement.