IMemoryCache Entry Invalidation (Manual Cache Busting)

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 UserDto must 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:

  1. Direct invalidation: call cache.Remove(key) with consistent keys.
  2. Targeted invalidation: centralize invalidation behind a small service (so writes invalidate reads).
  3. Token-based invalidation: attach an IChangeToken to 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.


Let's Work Together

Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.

New Platforms
Modernization
Training & Consulting

Comments

Twitter Facebook LinkedIn WhatsApp