
IMemoryCache is great for speeding up expensive operations (database reads, HTTP calls, heavy computations). But many real systems need more than a TTL:
- You cache a value.
- The underlying data changes.
- You want to invalidate the cache entry immediately.
Typical scenarios:
- A user updates their profile → cached
UserDtomust be refreshed. - An admin flips a setting/feature → cached configuration must be cleared.
- A background job writes a new catalog version → all catalog caches must be busted.
This post focuses on entry invalidation: how to remove (or expire) cached values on demand - without sprinkling cache.Remove(...) everywhere.
The core idea
There are three practical levels of invalidation:
- Direct invalidation: call
cache.Remove(key)with consistent keys. - Targeted invalidation: centralize invalidation behind a small service (so writes invalidate reads).
- Token-based invalidation: attach an
IChangeTokento cache entries and cancel tokens to invalidate many entries at once (per key / per group).
Step 1: Use consistent cache keys
Before you invalidate anything, make cache keys predictable.
1public static class CacheKeys
2{
3 public static string User(string userId) => $"user:{userId}";
4 public const string Catalog = "catalog";
5}
Then reads and writes share the same key functions.
Step 2: Manual invalidation with Remove() (but centralized)
If you only need to invalidate one entry at a time, the simplest option is still the best:
1cache.Remove(CacheKeys.User(userId));
The problem is not Remove() - it’s when you do it in 20 places.
Create a small invalidator service and use it from write paths:
1using Microsoft.Extensions.Caching.Memory;
2
3public interface ICacheInvalidator
4{
5 void InvalidateUser(string userId);
6 void InvalidateCatalog();
7}
8
9public sealed class CacheInvalidator : ICacheInvalidator
10{
11 private readonly IMemoryCache _cache;
12
13 public CacheInvalidator(IMemoryCache cache)
14 {
15 _cache = cache;
16 }
17
18 public void InvalidateUser(string userId)
19 => _cache.Remove(CacheKeys.User(userId));
20
21 public void InvalidateCatalog()
22 => _cache.Remove(CacheKeys.Catalog);
23}
This makes invalidation explicit and discoverable.
Step 3: Token-based invalidation (per key / per group)
If you want to invalidate multiple entries without tracking every key manually, use IChangeToken.
The pattern:
- cache entries attach a token
- you cancel the token to invalidate all entries that depend on it
An invalidation token source
1using System.Collections.Concurrent;
2using Microsoft.Extensions.Primitives;
3
4public interface ICacheInvalidationTokens
5{
6 IChangeToken GetToken(string scope);
7 void Invalidate(string scope);
8}
9
10public sealed class CacheInvalidationTokens : ICacheInvalidationTokens
11{
12 private readonly ConcurrentDictionary<string, CancellationTokenSource> _sources =
13 new(StringComparer.Ordinal);
14
15 public IChangeToken GetToken(string scope)
16 {
17 var cts = _sources.GetOrAdd(scope, static _ => new CancellationTokenSource());
18 return new CancellationChangeToken(cts.Token);
19 }
20
21 public void Invalidate(string scope)
22 {
23 if (_sources.TryRemove(scope, out var cts))
24 {
25 cts.Cancel();
26 cts.Dispose();
27 }
28 }
29}
Notes:
- Tokens are single-use: after invalidation you remove the CTS; next
GetToken(scope)creates a new one.
Using the token when caching
1using Microsoft.Extensions.Caching.Memory;
2
3public sealed class CatalogService
4{
5 private readonly IMemoryCache _cache;
6 private readonly ICacheInvalidationTokens _tokens;
7
8 public CatalogService(IMemoryCache cache, ICacheInvalidationTokens tokens)
9 {
10 _cache = cache;
11 _tokens = tokens;
12 }
13
14 public Task<Catalog> GetCatalogAsync()
15 {
16 return _cache.GetOrCreateAsync(CacheKeys.Catalog, entry =>
17 {
18 entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
19 entry.AddExpirationToken(_tokens.GetToken("catalog"));
20
21 return FetchCatalogAsync();
22 })!;
23 }
24
25 public void InvalidateCatalog()
26 => _tokens.Invalidate("catalog");
27
28 private static Task<Catalog> FetchCatalogAsync()
29 => throw new NotImplementedException();
30}
Now you can invalidate the whole “catalog” scope from anywhere (admin action, webhook, background job) without knowing all keys.
Handling cache stampedes (important)
Even with invalidation, there’s a subtle problem under load:
- Many concurrent requests see the entry as invalid/missing.
- All of them recompute at the same time.
This is a cache stampede.
The easiest fix is to add a per-key lock (or a Lazy<Task<T>> approach) so only one request refreshes while others await.
Here’s a pragmatic keyed-lock implementation using ConcurrentDictionary + SemaphoreSlim:
1using System.Collections.Concurrent;
2using Microsoft.Extensions.Caching.Memory;
3
4public sealed class InvalidatingCache
5{
6 private readonly IMemoryCache _cache;
7 private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new(StringComparer.Ordinal);
8
9 public InvalidatingCache(IMemoryCache cache)
10 {
11 _cache = cache;
12 }
13
14 public async Task<T> GetOrCreateAsync<T>(
15 string key,
16 Func<ICacheEntry, Task<T>> factory)
17 {
18 if (_cache.TryGetValue(key, out var boxed) && boxed is T value)
19 {
20 return value;
21 }
22
23 var gate = _locks.GetOrAdd(key, static _ => new SemaphoreSlim(1, 1));
24 await gate.WaitAsync();
25
26 try
27 {
28 if (_cache.TryGetValue(key, out boxed) && boxed is T v)
29 {
30 return v;
31 }
32
33 var created = await _cache.GetOrCreateAsync(key, factory);
34 return created ?? throw new InvalidOperationException("Cache factory returned null.");
35 }
36 finally
37 {
38 gate.Release();
39 }
40 }
41}
Notes on keyed locks
- This prevents stampedes with very little code.
- The dictionary can grow with many distinct keys. If you cache an unbounded key space, consider a bounded lock strategy.
What should trigger invalidation?
Good invalidation triggers are events where you know a cached value is stale:
- after a successful DB update
- after processing an incoming event/webhook
- after an admin setting change
- after deploying a new version of data (catalog, rules, etc.)
Try to invalidate as close to the write as possible.
When to prefer change tokens instead
If you control invalidation events, IChangeToken-based expiration is often cleaner than on-read validation:
- invalidate cached entries when a configuration file changes
- invalidate when your app receives a message (“catalog updated”)
Manual invalidation shines when you can connect invalidation to your own business events.

Comments