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.


Comments

Twitter Facebook LinkedIn WhatsApp