CachingRedisBackendSystem DesignPerformance

Cache Invalidation Strategies Explained (With Redis Examples)

Learn what cache invalidation is and how to do it: TTL, event-based, cache-aside, write-through, version keys and tags, plus the race conditions and cache stampedes that cause stale data.

Kashyap Kumar·
Cache Invalidation Strategies Explained (With Redis Examples)

Almost every application caches something. Instead of asking the database the same question again and again, the application saves a copy of the answer somewhere fast, usually Redis, and serves that copy instead.

The hard part is not putting data into a cache. The hard part is knowing when that copy stops being true. As soon as someone changes the original row, the cached copy is wrong, and nothing in the database tells the cache about it.

Cache invalidation is how you handle that. It means removing or replacing a cached value after the data behind it changes. This guide explains the main cache invalidation strategies, shows the code for each one, and covers the two problems that break caches even when the strategy is correct.

TL;DR

Here is the whole guide in one list. Read the section for the strategy you pick.

  • TTL deletes a key after a set time. It is the easiest to add and the best backstop.
  • Event-based invalidation deletes the key the moment the data changes, so you must catch every write.
  • Cache-aside reads the cache, falls back to the database, and deletes on a write. Start here.
  • Write-through writes to both stores together, so the cache is never empty after a write.
  • Version-based invalidation changes the key instead of deleting the value. It suits large objects.
  • Tag-based invalidation clears a group of related keys in one step.

Why Cache Invalidation Is Necessary

To see why any of this is needed, follow one value through a cache.

Your application stores a user profile in Redis. The database holds user:123 with the name Rahul, and so does the cache. That user changes their name to Rohan, so the application updates the database, and the write succeeds.

The cache does not change. Redis was never part of that update, and the database has no way to tell it that the row has moved on.

Stale data: the database holds the new name while the cache still holds the old one
Stale data: the database holds the new name while the cache still holds the old one

The next request asks for user:123. The cache has a value, so it returns Rahul and never touches the database. Nothing crashed and nothing reached your logs, but the user sees a name that no longer exists in your database. This is stale data.

Every strategy below answers the same question. When the original changes, what happens to the copy?

TTL-Based Cache Invalidation

The first strategy ignores the change and puts a deadline on the copy instead. TTL stands for time to live, and in Redis it is one extra argument on the write.

await redis.set('user:123', JSON.stringify(user), { EX: 300 })

EX: 300 tells Redis to delete the key after 300 seconds. The next read then finds nothing, so the application goes to the database, takes the current value, and caches it again. You wrote no invalidation code, because the cache clears itself.

TTL fits any data where being slightly out of date does no harm, such as a list of popular articles or an exchange rate. The limit is simple: until the TTL runs out, your users can see the old value, and you cannot make it happen sooner.

TTL is not only a caching idea. DNS records carry one too, which is why a domain change takes time to reach everyone. See How DNS Works Explained.

Event-Based Cache Invalidation

TTL waits for a clock. Event-based invalidation does the opposite and removes the cached value at the moment the data changes, which in code is one line beside your database write.

await db.users.update(userId, patch)
await redis.del(`user:${userId}`)

After the delete, the key is gone. The next request finds an empty cache, reads the current row, and stores that instead, so the old value is never served again. This is the strategy for data where being out of date matters, such as profiles, permissions, or stock levels.

Its weakness is coverage, not correctness. Every part of your system that changes a user must remember to delete that user's key, including background jobs and admin scripts. Miss one and you get a stale entry that no test and no error log will show you.

The Cache-Aside Pattern (Lazy Loading)

The two strategies above each describe one step. Cache-aside describes the whole shape, and it is what most applications end up using. The application talks to both stores itself, and nothing is cached until somebody asks for it.

A read checks the cache first, and only falls back to the database when the cache is empty.

const cached = await redis.get(`user:${id}`)
if (cached) return JSON.parse(cached)

const user = await db.users.findById(id)
await redis.set(`user:${id}`, JSON.stringify(user), { EX: 300 })
return user

A write updates the database and then deletes the key, which is the event-based step from the section above.

The read path and the write path of the cache-aside pattern
The read path and the write path of the cache-aside pattern

That delete is worth explaining. If two writes happen close together and both set the key, the slower one can arrive last and leave its older value behind. Deleting cannot go wrong that way, and it costs one cache miss.

Write-Through Caching

Cache-aside leaves the cache empty after every write. Write-through does the opposite and fills it, because the cache and the database are written as two halves of the same operation.

Cache-aside deletes the key on a write while write-through replaces it
Cache-aside deletes the key on a write while write-through replaces it

Since both stores are written together, a read straight after a write still gets a hit, and nothing has to be rebuilt. The cost is that every write now pays for a cache operation too, even for rows nobody reads afterwards, and the two stores can still disagree if one call fails.

Write-through therefore suits data that is read constantly and written rarely, such as a product catalogue. It is a poor fit for tables where writes are frequent and most rows are never read back.

Version-Based Cache Invalidation

Every strategy so far removes the old value. Version-based invalidation leaves it alone and changes the key instead.

product:123:v1   ← the old key, now ignored
product:123:v2   ← the new key, read from here

When the data changes, the application raises the version number and starts reading the new key. Nothing has to be rebuilt while traffic waits for it, which makes this useful for large cached objects that are slow to build, such as a rendered page or a report.

The old versions are still sitting in memory, so give them a TTL. Without one, every change leaves another abandoned copy behind.

Tag-Based Cache Invalidation

Version keys handle one value that is expensive to rebuild. Tags handle a different problem: one change making several cached entries wrong at once.

Think about a single product being edited in a shop. That one change affects the product's own key, the category listing it appears in, the featured block on the homepage, and any cached search results holding it.

product:123
category:electronics:products
homepage:featured-products
search:iphone

Deleting those four keys by hand works until someone adds a fifth cached view and forgets the list. Tagging avoids that. You attach a tag such as product:123 to every entry built from that product, and invalidating the tag clears all of them together.

Cache Invalidation Race Conditions

Everything above assumes your steps run one after another, in the order you wrote them. Once your application runs on more than one instance that stops being true, because two requests can be partway through their own steps at the same time.

Here is the case that catches people out. Request A reads the database and gets Rahul. Before A can store it, request B updates the row to Rohan and deletes the cache key. Request A then carries on and writes what it read, which is Rahul, into the cache.

A cache invalidation race condition, where a read and a write overlap
A cache invalidation race condition, where a read and a write overlap

Both requests did what their code told them to do, and the delete ran at the right time. The problem is only the order, because A wrote after B deleted, so the stale data is back. Every later read now gets a hit, and nothing corrects it until the key expires.

That is why you should put a TTL on keys you also delete by hand. It does not stop the race, but it limits the damage to the length of the TTL rather than forever. If that is still too long, use a lock while the value is rebuilt, or a version check that will not overwrite a newer value with an older one.

Cache Stampede (Thundering Herd)

A race condition leaves you with a wrong value. The next problem is the opposite, because the value is right but it arrives at a terrible moment.

Picture a key that many requests read at once, such as a homepage feed, and its TTL runs out. For the few milliseconds until something replaces it, every request that wanted it misses, and each one runs the same expensive query.

A cache stampede, and the same key rebuilt once behind a single-flight lock
A cache stampede, and the same key rebuilt once behind a single-flight lock

This is a cache stampede, also called a thundering herd. The usual fix is single-flight. The first request takes a short lock on the key and rebuilds the value, and everyone else waits for it instead of repeating the work.

const lock = await redis.set(`lock:user:${id}`, '1', { NX: true, EX: 5 })
if (!lock) {
    await sleep(50)
    return getUser(id)
}

The same problem has a smaller version. If you cache a thousand keys in one loop with the same 300-second TTL, all thousand expire in the same second, so spread them by a random few seconds.

What Happens When Cache Invalidation Fails

All of this still assumes the delete succeeded, and sometimes it does not. Redis can be unreachable, or the process can die between the database commit and the delete. The cache then keeps stale data and nobody knows.

There are three defences. The TTL you already have limits how long that value survives. An outbox table records the invalidation in the same transaction as the write, so a worker can retry it until it succeeds. Change data capture drives invalidation from the database change log, so application code cannot skip it. Most teams only need the first.

How to Design Cache Keys

All of this depends on something easy to overlook: you can only remove the entries you can name. A predictable naming scheme makes it obvious which keys a change affects.

user:123
user:123:orders
product:456
category:electronics:products

Without one, teams make one of two mistakes. The first is invalidating too much, usually by clearing the whole cache because one product changed. Every cached key then has to be rebuilt at once, which sends the database the flood of queries you were caching to avoid. The second is caching a value and never deciding how it becomes invalid, which is how a cache turns into a source of wrong answers.

How to Choose a Cache Invalidation Strategy

If you are starting from nothing, use cache-aside with a TTL. Read through the cache, delete the key when you write, and give every key an expiry as a backstop. That covers most web applications.

After that, let the data decide. For each thing you cache, ask how often it changes and what happens if the invalidation never runs. A weather reading can be minutes behind and nobody is harmed, but an account balance or a stock count cannot, so those need the stronger fixes above. Caching is about deciding how long a copy can still be trusted, and cache invalidation is where you write that decision down.

Frequently Asked Questions

What is cache invalidation?

Cache invalidation is the process of removing or replacing cached data once the data behind it changes, so that the application stops serving a value that is no longer true.

Why is cache invalidation so hard?

A cache is a second copy that the original knows nothing about. Keeping it correct means catching every piece of code that writes, across requests that run at the same time. Miss one and you get a bug that no error log reports.

Should I delete the cache key or update it?

Delete it in almost every case. If two writes both update the same key, the slower one can land last and leave an older value behind. A delete cannot fail that way, and it costs one cache miss.

What is the difference between cache invalidation and cache refresh?

Invalidation removes the value and lets the next read rebuild it. Refresh fetches the new value and writes it straight away, which avoids a cache miss but pays for a write that may never be read.

Caching comes up in most backend and system design interviews. For more questions in this format, see Top 111 Backend Interview Questions. For the database behind the cache, see SQL Made Dead Simple.