Cache Invalidation Strategies: TTL, Explicit Removal, Event-Driven Invalidation and Versioned Keys
Correct invalidation means tying the delete to the commit and bounding the remaining race with a TTL, generation keys or events: in Bookstore even "write, then delete" left the old 12.00 price in the cache for 600 seconds, an interceptor deleting in SavedChangesAsync made it worse under an explicit transaction, and TransactionCommittedAsync fixed it. Generation keys closed the race on the same rig, and Redis pub/sub cleared the second instance's L1 in 0.7 ms.
Version note: The code in this post was built and run with .NET 10; the examples ran with Entity Framework Core (EF Core) 10.0.14 (SQLite), StackExchange.Redis 3.4.31 and Redis 8.2.
Guess First
Bookstore's price update code follows the order the System Design series recommends: write to the database first, then delete the book:42 key in Redis. The price goes from 12.00 to 19.90. At that very moment a read request has missed the cache, read 12.00 from the database and is about to write it to Redis. A small GC (garbage collection) pause delays the read by a few milliseconds.
Two questions. After the write finishes, which price is in the cache, and for how long? Second: so nobody forgets the delete in some endpoint, the team moves it into an EF Core SaveChanges interceptor, into SavedChangesAsync. The price update also writes an audit row in the same transaction, so it uses an explicit transaction with BeginTransaction. Does the interceptor fix the problem or make it worse?
Options and Their Costs
The theory of invalidation, meaning the "write, then delete" order in cache-aside, why deleting is safer than updating, and the race between a read and a write, was covered in the System Design series post "Caching Strategies: Cache-Aside, Write-Through, Write-Behind and Cache Stampede"; Facebook's paper that solves this race with leases is discussed there too. This post goes down to the .NET side: we reproduce the race deterministically in code and measure every fix on the same rig. First, the options and their costs:
| Strategy | Staleness window | Cost | In Bookstore |
|---|---|---|---|
| TTL (Time to Live) only | Up to the TTL | No code; predictable staleness | Bestseller list |
| Delete on write | The race window, at worst the TTL | Every path that changes data must know the cache | Book details |
Write-through (SET on write) | Reordering under concurrent writes | Slower write path | Not used |
| Group invalidation with tags | Whatever the tag covers | Tag bookkeeping; logical in HybridCache | Category menu |
| Versioned and generation keys | Zero; old keys stay in memory until TTL | One extra GET, orphaned keys | Category pages |
| Event-driven invalidation | Message latency | Broker, outbox, idempotent consumer | Price changes from another service |
| L1 (level 1, in-process cache) via Redis pub/sub | Milliseconds; messages can be lost | A short L1 TTL is still required | In-process book cache |
Every row in the table has one thing in common: the TTL never goes away. Explicit deletes, events or pub/sub shorten the staleness window; but when a message is lost, when a process crashes before deleting, or when one of the races below happens, the TTL is the last line of defense.
Reproducing the Stale Set Deterministically
Instead of describing the race as "it happens sometimes", we want to trigger it on demand, so we add a test hook to the cache-aside read. The hook runs after reading from the database but before writing to the cache:
// classic cache-aside; afterDbRead is a test hook that freezes the reader between the read and the set
public async Task<BookDto> GetAsync(int id, Func<Task>? afterDbRead = null)
{
string key = $"book:{id}";
RedisValue cached = await redis.StringGetAsync(key);
if (cached.HasValue) return JsonSerializer.Deserialize<BookDto>(cached.ToString())!;
await using CatalogDb db = newDb();
BookDto book = await db.Books.AsNoTracking().Where(b => b.Id == id)
.Select(b => new BookDto(b.Id, b.Title, b.Price)).SingleAsync();
log($"reader: cache miss, read price {book.Price:F2} from db");
if (afterDbRead is not null) await afterDbRead();
await redis.StringSetAsync(key, JsonSerializer.Serialize(book), Ttl);
log($"reader: set {key} = {book.Price:F2}");
return book;
}
The scenario locks the order with two TaskCompletionSource instances: the reader reads the old price and waits, the writer finishes its work in the "right" order, then the reader continues:
// reader misses and reads the old price, then stalls before the set
(Func<Task> hook, Task readDone, TaskCompletionSource resume) = Gate();
Task<BookDto> reader = cache.GetAsync(42, hook);
await readDone;
// writer in the "right" order: database first, then delete
await UpdatePriceAsync(42, 19.90m);
await redis.KeyDeleteAsync("book:42");
Log("writer: deleted book:42");
resume.SetResult();
await reader;
await ReportAsync();
== stale-set
71 ms reader: cache miss, read price 12.00 from db
94 ms writer: price 19.90 committed
96 ms writer: deleted book:42
104 ms reader: set book:42 = 12.00
110 ms result: db=19.90 cache=12.00 ttl=600s
== delete-first
1 ms writer: deleted book:42
73 ms reader: cache miss, read price 12.00 from db
80 ms reader: set book:42 = 12.00
103 ms writer: price 19.90 committed
110 ms result: db=19.90 cache=12.00 ttl=600s
The answer to the first question: 12.00, for a full 600 seconds. The delete ran before the reader's SET, so it deleted nothing; the reader then wrote its old value with a fresh TTL. The second scenario is the order that deletes before touching the database. The result is the same, but the window is much wider: any read during the whole write can put the old value back. "Write, then delete" doesn't make the race impossible; it only narrows the window to the reader's short gap between its read and its set. A timeline of the three cases:

Invalidating After Commit: A SaveChanges Interceptor
Writing the delete into every endpoint guarantees that one of them will forget it. EF Core interceptors run regardless of which code path made the change, so they are a natural home; the basics are in the EF Core series post "EF Core Interceptors and Events: Audit Fields and Slow Query Logging". The first attempt was an interceptor that collected the changed books' keys in SavingChangesAsync and deleted them in SavedChangesAsync. I ran it with an explicit transaction, as in the guess:
== tx-naive
72 ms reader: cache miss, read price 12.00 from db
80 ms reader: set book:42 = 12.00
110 ms invalidator: deleted book:42
110 ms writer: SaveChanges done, transaction still open
112 ms reader: cache miss, read price 12.00 from db
113 ms reader: set book:42 = 12.00
113 ms writer: committed
119 ms result: db=19.90 cache=12.00 ttl=600s
The answer to the second question: the interceptor made it worse. SavedChangesAsync runs when SaveChanges finishes, not when the transaction commits. The delete happened before the commit; the read in between couldn't see the uncommitted change in the database and put 12.00 back into the cache. Transaction details are in the EF Core series post "Transactions in EF Core: SaveChanges, BeginTransaction and Savepoints". The fix is to tie the delete to the commit:
// collects keys in SavingChanges, invalidates only after the transaction is committed
public sealed class CommitAwareInvalidationInterceptor(ICacheInvalidator invalidator, ILogger<CommitAwareInvalidationInterceptor> logger)
: SaveChangesInterceptor, IDbTransactionInterceptor
{
private readonly HashSet<string> _pending = [];
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
_pending.UnionWith(CacheKeys.For(eventData.Context!));
return ValueTask.FromResult(result);
}
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
{
// an explicit transaction is still open: wait for the commit
if (eventData.Context!.Database.CurrentTransaction is null)
await FlushAsync(cancellationToken);
return result;
}
public async Task TransactionCommittedAsync(
DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default) =>
await FlushAsync(cancellationToken);
public Task TransactionRolledBackAsync(
DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default)
{
_pending.Clear();
return Task.CompletedTask;
}
private async Task FlushAsync(CancellationToken ct)
{
if (_pending.Count == 0) return;
string[] keys = [.. _pending];
_pending.Clear();
try
{
await invalidator.InvalidateAsync(keys, ct);
}
catch (RedisException ex)
{
// the commit already happened; the entry lives until its TTL (dual write, see the outbox)
logger.LogWarning(ex, "Invalidation failed for {Keys}", string.Join(", ", keys));
}
}
}
== tx-commit
73 ms reader: cache miss, read price 12.00 from db
82 ms reader: set book:42 = 12.00
107 ms writer: SaveChanges done, transaction still open
112 ms invalidator: deleted book:42
112 ms writer: committed
116 ms result: db=19.90 cache=(empty)
The read before the commit returned the 12.00 in the cache; that's correct, because the change doesn't exist yet. The delete ran inside TransactionCommittedAsync and the cache stayed empty. The interceptor holds a HashSet, so it's stateful; that's why it isn't a singleton, and each DbContext gets its own instance:
builder.Services.AddScoped<CommitAwareInvalidationInterceptor>();
builder.Services.AddDbContext<CatalogDb>((sp, o) => o
.UseSqlite("Data Source=../shop.db")
.AddInterceptors(sp.GetRequiredService<CommitAwareInvalidationInterceptor>()));
One gap remains: the commit succeeds, but the process crashes before the delete, or Redis is unreachable at that moment. The database and the cache are two separate systems and you can't write to both atomically; this is exactly the dual write problem from the Messaging and Event-Driven Architecture series post "Transactional Outbox and Inbox: Understanding and Designing Around the Dual Write Problem". If you can't afford to lose an invalidation, write the keys to an outbox table in the same transaction and leave the delete to a background dispatcher. For most caches, the catch block in the interceptor plus the TTL is enough: a lost invalidation means at most one TTL of staleness.
A natural question: why not leave all this to a ready-made EF Core second-level cache library? These libraries key query results by the SQL (Structured Query Language) text and parameters and invalidate everything that touches a table on the same app's SaveChanges. In Bookstore we pass on them for three reasons. Invalidation is table-level, so a single price change wipes every cached query on the Books table and the hit ratio collapses on a frequently written table. They can't see writes that don't go through the same DbContext (another service, an import job, hand-run SQL). Most importantly, they bury the decision of what is cached, for how long and with which consistency promise inside the query; none of the races in this post go away, they just become invisible.
When a Delayed Double Delete Helps
Deleting after commit doesn't close the stale set race; it only closes the window caused by the transaction. A trick often suggested for the rest of the race is to repeat the delete after a while. I set the reader's stall first to 100 ms, then to 800 ms; the second delete comes 500 ms later:
Task<BookDto> reader = cache.GetAsync(42, () => Task.Delay(readerStallMs));
await Task.Delay(20);
await UpdatePriceAsync(42, 19.90m);
await redis.KeyDeleteAsync("book:42");
Log("writer: deleted book:42");
Task second = Task.Run(async () =>
{
await Task.Delay(500);
await redis.KeyDeleteAsync("book:42");
Log("writer: deleted book:42 again (+500 ms)");
});
== double-delete 100
72 ms reader: cache miss, read price 12.00 from db
73 ms writer: price 19.90 committed
75 ms writer: deleted book:42
185 ms reader: set book:42 = 12.00
579 ms writer: deleted book:42 again (+500 ms)
1519 ms result: db=19.90 cache=(empty)
== double-delete 800
70 ms reader: cache miss, read price 12.00 from db
72 ms writer: price 19.90 committed
73 ms writer: deleted book:42
576 ms writer: deleted book:42 again (+500 ms)
894 ms reader: set book:42 = 12.00
1523 ms result: db=19.90 cache=12.00 ttl=599s
With a 100 ms stall, the second delete cleared the stale SET and the staleness dropped to about 400 ms (the third panel in the figure). At 800 ms the second delete came too early and 12.00 stayed in the cache with a 599-second TTL. The rule is simple: a double delete only helps if the delay is longer than the longest time a reader can spend between its read and its set. You can't know that time; a GC pause, a slow Redis connection or a starved thread pool can stretch it to seconds. A longer delay has a cost too: it also deletes a legitimate fresh value that filled the cache just before the second delete. Treat double delete not as a fix that closes the race completely, but as a cheap add-on that cuts staleness from the TTL down to the delay. Run the second delete from somewhere durable (an outbox, a background job queue), not with Task.Run as in the sample; otherwise it gets lost during a deploy.
Versioned and Generation Keys
The way to remove the race at its root is to make sure nobody reads the place where the stale reader writes. A generation key does that: the key contains a counter, and invalidation increments the counter instead of deleting the value. For category pages:
// generation key: one INCR makes every page of the category unreachable
public async Task<List<BookDto>> GetCategoryPageAsync(int categoryId, int page, Func<Task>? afterDbRead = null)
{
long generation = (long?)await redis.StringGetAsync($"gen:category:{categoryId}") ?? 0;
string key = $"books:category:{categoryId}:g{generation}:page:{page}";
RedisValue cached = await redis.StringGetAsync(key);
if (cached.HasValue) return JsonSerializer.Deserialize<List<BookDto>>(cached.ToString())!;
await using CatalogDb db = newDb();
List<BookDto> books = await db.Books.AsNoTracking().Where(b => b.CategoryId == categoryId)
.OrderBy(b => b.Id).Skip((page - 1) * 20).Take(20)
.Select(b => new BookDto(b.Id, b.Title, b.Price)).ToListAsync();
log($"reader: miss on {key}, read from db");
if (afterDbRead is not null) await afterDbRead();
await redis.StringSetAsync(key, JsonSerializer.Serialize(books), Ttl);
log($"reader: set {key}");
return books;
}
public async Task BumpCategoryAsync(int categoryId)
{
long generation = await redis.StringIncrementAsync($"gen:category:{categoryId}");
log($"writer: gen:category:{categoryId} -> {generation}");
}
The same stale set rig, this time calling BumpCategoryAsync after the commit instead of deleting:
== generation
72 ms reader: miss on books:category:0:g0:page:1, read from db
95 ms writer: price 19.90 committed
97 ms writer: gen:category:0 -> 1
106 ms reader: set books:category:0:g0:page:1
108 ms reader: miss on books:category:0:g1:page:1, read from db
109 ms reader: set books:category:0:g1:page:1
109 ms next reader sees book 42 at 19.90
113 ms redis: books:category:0:g1:page:1 book 42 = 19.90
114 ms redis: books:category:0:g0:page:1 book 42 = 12.00
The stalled reader still wrote 12.00, but to the g0 key; since the counter is now 1, nobody reads that key. The next read missed g1 and fetched 19.90. As long as you increment the counter after the commit, every reader that sees the new counter reads committed data. The second win is groups: however many pages of the category are cached, a single INCR invalidates them all; no SCAN for keys, no tag lists.
The cost has three parts. One extra GET per read (the counter). Orphaned keys: the g0 pages stay in Redis memory until their TTL runs out, so generation keys are never written without a TTL. And the counter itself can disappear; if Redis evicts it, it starts again from zero, and if the old g0 keys are still there they come back to life. That's why it matters to keep the counter key without a TTL and to make sure the eviction policy won't drop it (the volatile-* policies from the third post of the series). A schema version in the key, like v1:, is the same idea applied to deployments: when the JSON (JavaScript Object Notation) shape changes, the version goes up and the old keys die on their own.
Event-Driven Invalidation: Clearing L1 with Redis Pub/Sub
So far, what we deleted lived in the shared Redis. L1, however, lives in each instance's memory; in the fourth post of the series we saw that HybridCache doesn't touch the L1 on other servers. I set up an API (Application Programming Interface) with two instances: each keeps book details in IMemoryCache for 5 minutes, and price changes go through the interceptor above. The ICacheInvalidator the interceptor calls removes the key from the local L1 and publishes a message on a Redis channel:
// removes keys from this instance's L1 and tells the other instances to do the same
public sealed class PubSubL1Invalidator(IMemoryCache l1, IConnectionMultiplexer redis, InstanceInfo me,
ILogger<PubSubL1Invalidator> logger) : ICacheInvalidator
{
public static readonly RedisChannel Channel = RedisChannel.Literal("bookstore:invalidate");
public async Task InvalidateAsync(IReadOnlyCollection<string> keys, CancellationToken ct)
{
foreach (string key in keys) l1.Remove(key);
if (!me.PubSub) return;
string message = JsonSerializer.Serialize(new InvalidationMessage(me.Name, [.. keys], DateTime.UtcNow));
long receivers = await redis.GetSubscriber().PublishAsync(Channel, message);
logger.LogInformation("{Instance} published {Keys}, receivers={Receivers}", me.Name, string.Join(",", keys), receivers);
}
}
await redis.GetSubscriber().SubscribeAsync(PubSubL1Invalidator.Channel, (_, value) =>
{
InvalidationMessage message = JsonSerializer.Deserialize<InvalidationMessage>(value.ToString())!;
if (message.Origin == me.Name) return;
foreach (string key in message.Keys) l1.Remove(key);
logger.LogInformation("{Instance} removed {Keys} from L1 ({Origin} -> {Instance}: {Ms:F1} ms)",
me.Name, string.Join(",", message.Keys), message.Origin, me.Name, (DateTime.UtcNow - message.SentAtUtc).TotalMilliseconds);
});
A (17533) and B (17534) share the same SQLite file and the same Redis. First with pub/sub off, then on:
== pubsub off
A: GET /api/books/42 {"price":12.0,"source":"db","instance":"A"}
B: GET /api/books/42 {"price":12.0,"source":"db","instance":"B"}
B: GET /api/books/42 {"price":12.0,"source":"L1","instance":"B"}
A: PUT price=19.90 204
A: GET /api/books/42 {"price":19.9,"source":"db","instance":"A"}
B: GET /api/books/42 {"price":12.0,"source":"L1","instance":"B"}
B: GET /api/books/42 {"price":12.0,"source":"L1","instance":"B"}
A: PUT price=21.00 204
B: GET /api/books/42 {"price":12.0,"source":"L1","instance":"B"}
== pubsub on
A: GET /api/books/42 {"price":12.0,"source":"db","instance":"A"}
B: GET /api/books/42 {"price":12.0,"source":"db","instance":"B"}
B: GET /api/books/42 {"price":12.0,"source":"L1","instance":"B"}
A: PUT price=19.90 204
A: GET /api/books/42 {"price":19.9,"source":"db","instance":"A"}
B: GET /api/books/42 {"price":19.9,"source":"db","instance":"B"}
B: GET /api/books/42 {"price":19.9,"source":"L1","instance":"B"}
A: PUT price=21.00 204
B: GET /api/books/42 {"price":21.0,"source":"db","instance":"B"}
A published book:42, receivers=2
A published book:42, receivers=2
B removed book:42 from L1 (A -> B: 11.0 ms)
B removed book:42 from L1 (A -> B: 0.7 ms)
With pub/sub off, B missed both price changes and would have served the 12.00 in its L1 for 5 minutes. With it on, B's L1 was cleared; the message arrived in 11 ms on the first update and in 0.7 ms on the second. receivers=2 counts A itself too; A skips its own message with the Origin check.
The Redis docs state the limit of this approach plainly: pub/sub has at-most-once delivery; a subscriber whose connection is down at that moment never gets the message. In my test I dropped the subscription connections with CLIENT KILL TYPE pubsub; StackExchange.Redis reconnected and resubscribed before the next update and the message arrived, but a message that lands in the gap is lost. So keep the L1 TTL short enough that you can live with the cost of a lost message. If messages must not be lost, publish the invalidation as a domain event through an outbox into a durable broker; that's a topic for the upcoming Message Queues series. Pub/sub itself and durable alternatives like Redis Streams are covered in the upcoming Database › Redis series. If you don't want to build this yourself, FusionCache's backplane does the same job; we compared it in the fourth post.
Production Notes
Measure invalidation too. Track the number of published and received messages, how often the interceptor's catch block runs, and the share of deletes that arrive before the L1 TTL as metrics. If one instance receives fewer messages than the others, it was disconnected for a while and its L1 may be stale.
Audit staleness by sampling. A small background job that compares the cached value with the database for a hundred random keys every hour exposes races no test will ever catch. If the mismatch rate isn't zero, verify that the TTL keeps the freshness promise you made for that data.
Make deletes idempotent and batched. Deleting the same key twice is harmless; the HashSet in the interceptor already merges duplicates within a transaction. For an import that updates thousands of rows, bumping a generation counter once is much cheaper than deleting keys one by one.
The interceptor doesn't see every path. ExecuteUpdateAsync and ExecuteDeleteAsync bypass the change tracker, so no keys are collected in SavingChanges; code that does bulk updates with these methods must call invalidation itself. If other systems write to the database directly, the database itself is the only source of truth: reading its change stream with CDC (Change Data Capture) and producing invalidations from it is the most robust path in that case.
Common Pitfalls
Deleting before the commit. That can be an interceptor that deletes in SavedChangesAsync, or a RemoveAsync call inside a transaction scope. When there's an explicit transaction, the delete must be tied to the commit.
Updating instead of deleting. In write-through, if two concurrent writes reach the cache in reverse order, the old value wins; a delete doesn't care about that order.
Treating double delete as a guarantee. A read that takes longer than the delay writes a stale value again after the second delete; the 800 ms scenario we measured is exactly that.
Leaving the generation counter open to eviction. If the counter is evicted and reset, old pages come back.
Relying on pub/sub and keeping the L1 TTL long. With at-most-once delivery, every lost message means staleness for as long as the L1 TTL.
Making a stateful interceptor a singleton. A shared HashSet mixes keys from different requests, and one request's rollback wipes another's invalidation.
A Small Experiment
★☆☆ In the stale-set scenario, lower the TTL from 10 minutes to 30 seconds and run it again. How did the staleness window change? Decide whether that number is acceptable for book details.
★★☆ Add a SaveChangesFailedAsync override to CommitAwareInvalidationInterceptor and show with a scenario that _pending is cleared when SaveChanges fails without an explicit transaction. Without it, what does the next successful SaveChanges delete?
★★★ Move book details to a generation key too: take the counter from a Version column in the database instead of a separate key (like the concurrency token in the EF Core series post "Concurrency in EF Core: Tokens, rowversion and DbUpdateConcurrencyException"). Run the stale set rig again and weigh the cost of the extra column you read against the race you close.
This post was about the correctness of invalidation: what gets deleted, when, and in which order. In the next post we move to three problems that bring caches down in production: stampede, penetration and avalanche. Request coalescing, negative caching, adding jitter to TTLs and keeping the app up while Redis is unreachable.