Cache Invalidation Strategies

Choose between TTL expiry, delete-on-write, write-through and event-driven invalidation, and know how much staleness each one allows.

Syntaxawait db.SaveChangesAsync(ct); // commit first await cache.RemoveAsync($"product:v1:{id}", ct); // then evict await bus.PublishAsync(new ProductChanged(id)); // other pods evict L1

Every cache serves data that was true when it was stored. Invalidation decides how long a reader can see something that is no longer true, and the honest answer is never zero unless you skip the cache. Start each design by writing that number down per entity: product descriptions can be five minutes stale, stock levels fifteen seconds, an account balance not at all.

The strategies

StrategyHow it worksStalenessCost
TTL onlyEntries expire; nothing reacts to writesUp to the TTLNone; the default backstop
Delete on write (cache-aside)Commit, then remove the key; next read reloadsA small race windowEvery writer must know the keys
Write-throughCommit, then set the new valueConcurrent writers can set out of orderCache always warm after writes
Versioned keysBump a generation number in the key; old keys age outNone for new readsOrphaned entries until TTL
TagsInvalidate groups (RemoveByTagAsync, EvictByTagAsync)As delete on writeTag design per write path
EventsWriters publish; every instance evicts its local copyEvent delivery latencyA bus, and handling lost events

Order of operations

Invalidate after the transaction commits, never before. Delete-then-commit lets a reader repopulate the cache from the old row between the two steps. Even commit-then-delete has a race: a reader misses, reads the old row, the writer commits and deletes, then the reader stores the old row. That window is milliseconds, but under load it happens, which is why every entry keeps a TTL even when you invalidate explicitly. Prefer delete over set on writes: two writers that commit A then B can set B then A, leaving A cached; deleting makes the next reader fetch whatever won.

Several instances

An in-process cache (IMemoryCache, HybridCache L1, the default output cache store) exists once per pod. A write evicts only the pod that handled it. The choices are a short local TTL, a shared cache only, or broadcasting invalidations through Redis pub/sub or your message broker so each pod evicts locally. Broadcasts are fire-and-forget: a pod that is restarting or disconnected misses the message, so the TTL is still the guarantee. If the invalidation must survive a crash between commit and publish, write it to an outbox in the same transaction.

Expiry stampedes and read-your-writes

Entries written together expire together: a warm-up job that loads 10,000 products with the same TTL sends 10,000 queries at the same second later. Add jitter (for example 10% random) to TTLs. Users expect to see their own change immediately even when others may not: after a write, return the committed data in the response and let the client use it, or bypass the cache for that user's next read.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Evict a product's cached price on all six pods within a second of a merchandiser's change by publishing the key on a Redis channel.
  • Invalidate an entire wholesale price list after a nightly import of 50,000 rows with one generation bump instead of 50,000 deletes.
  • Spread the expiry of 10,000 pre-warmed product entries over a minute with TTL jitter so the database does not take 10,000 queries at once.

More examples

Broadcasting evictions with Redis pub/sub

Each pod subscribes to one channel and removes the named key from its own <code>IMemoryCache</code>. Redis pub/sub has no persistence or acknowledgement: a pod that is reconnecting when the message is published never sees it, so the listener clears its local cache on <code>ConnectionRestored</code> and the local TTL must still be short enough to be acceptable. <code>AbortOnConnectFail = false</code> keeps a Redis outage at boot from stopping the API. The publisher also receives its own message, which is harmless because removing a missing key does nothing.

Example · csharp
using Microsoft.Extensions.Caching.Memory;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
{
    var options = ConfigurationOptions.Parse(builder.Configuration.GetConnectionString("Redis")!);
    options.AbortOnConnectFail = false; // a Redis outage at boot must not stop the API from starting
    return ConnectionMultiplexer.Connect(options);
});
builder.Services.AddHostedService<CacheInvalidationListener>();
builder.Services.AddSingleton<CacheInvalidationPublisher>();
builder.Services.AddSingleton<PriceRepository>();

var app = builder.Build();
app.MapPut("/products/{sku}/price", async (string sku, PriceChange change, PriceRepository prices,
    IMemoryCache cache, CacheInvalidationPublisher publisher, CancellationToken ct) =>
{
    await prices.SaveAsync(sku, change.Price, ct);    // 1. commit
    cache.Remove($"price:v1:{sku}");                  // 2. evict this pod now
    await publisher.PublishAsync($"price:v1:{sku}");  // 3. every other pod evicts on the message
    return TypedResults.NoContent();
});
app.Run();

public sealed record PriceChange(decimal Price);

// Stands in for the real database write; the cache code does not depend on how it is stored.
public sealed class PriceRepository
{
    private readonly Dictionary<string, decimal> _prices = [];

    public async Task SaveAsync(string sku, decimal price, CancellationToken ct)
    {
        await Task.Delay(10, ct);
        lock (_prices) _prices[sku] = price;
    }
}

public sealed class CacheInvalidationPublisher(IConnectionMultiplexer redis)
{
    public static readonly RedisChannel Channel = RedisChannel.Literal("catalog-api:cache-invalidation");

    public Task PublishAsync(string key) => redis.GetSubscriber().PublishAsync(Channel, key);
}

public sealed class CacheInvalidationListener(IConnectionMultiplexer redis, IMemoryCache cache, ILogger<CacheInvalidationListener> logger)
    : IHostedService
{
    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Messages published while this pod was disconnected are lost, so drop the whole local cache on reconnect.
        redis.ConnectionRestored += (_, _) =>
        {
            if (cache is MemoryCache memory) memory.Clear();
            logger.LogInformation("Redis reconnected; cleared the local cache");
        };
        return redis.GetSubscriber().SubscribeAsync(CacheInvalidationPublisher.Channel, (_, key) =>
        {
            cache.Remove(key.ToString());
            logger.LogDebug("Evicted {CacheKey} on invalidation message", key.ToString());
        });
    }

    public Task StopAsync(CancellationToken cancellationToken) =>
        redis.GetSubscriber().UnsubscribeAsync(CacheInvalidationPublisher.Channel);
}

Evicting a group of IMemoryCache entries with a change token

<code>IMemoryCache</code> has no tags, but an entry can depend on an <code>IChangeToken</code>. Every page of a category shares one <code>CancellationTokenSource</code>; cancelling it expires them all without knowing their keys, and the next read creates a fresh source. This is the in-process equivalent of <code>RemoveByTagAsync</code>, and like it, it affects only the current pod.

Example · csharp
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Primitives;

public sealed record CategoryPage(string Category, int Page, IReadOnlyList<string> Skus);

public sealed class CategoryPageCache(IMemoryCache cache)
{
    private readonly Lock _gate = new();
    private readonly Dictionary<string, CancellationTokenSource> _groups = [];

    public CategoryPage GetOrAdd(string category, int page, Func<CategoryPage> load) =>
        cache.GetOrCreate(("category-page", category, page), entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            entry.AddExpirationToken(new CancellationChangeToken(GroupToken(category)));
            return load();
        })!;

    // Expires every page of the category at once, however many were cached.
    public void InvalidateCategory(string category)
    {
        CancellationTokenSource? old;
        lock (_gate)
        {
            _groups.Remove(category, out old);
        }
        old?.Cancel();
        old?.Dispose();
    }

    private CancellationToken GroupToken(string category)
    {
        lock (_gate)
        {
            if (!_groups.TryGetValue(category, out var cts))
                _groups[category] = cts = new CancellationTokenSource();
            return cts.Token;
        }
    }
}

Versioned keys: invalidate a whole price list by bumping a generation

A bulk import that changes 50,000 prices cannot delete 50,000 keys cheaply, and <code>IDistributedCache</code> has no prefix delete. Reading a generation number first and putting it in every key means one write invalidates the whole set; the old entries are never read again and fall out with their TTL. The cost is an extra cache read per lookup, which HybridCache tags avoid by doing the same bookkeeping internally.

Example · csharp
using Microsoft.Extensions.Caching.Distributed;

public sealed class PriceListCache(IDistributedCache cache)
{
    private static readonly DistributedCacheEntryOptions GenerationTtl = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(7) };
    private static readonly DistributedCacheEntryOptions PriceTtl = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };

    public async Task<string?> GetPriceJsonAsync(string priceList, string sku, CancellationToken ct)
    {
        var generation = await CurrentGenerationAsync(priceList, ct);
        return await cache.GetStringAsync($"price:{priceList}:g{generation}:{sku}", ct);
    }

    public async Task SetPriceJsonAsync(string priceList, string sku, string json, CancellationToken ct)
    {
        var generation = await CurrentGenerationAsync(priceList, ct);
        await cache.SetStringAsync($"price:{priceList}:g{generation}:{sku}", json, PriceTtl, ct);
    }

    // After a bulk import commits: every old key is unreachable at once and expires on its own.
    public Task InvalidateAsync(string priceList, CancellationToken ct) =>
        cache.SetStringAsync(GenerationKey(priceList), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(), GenerationTtl, ct);

    private async Task<string> CurrentGenerationAsync(string priceList, CancellationToken ct) =>
        await cache.GetStringAsync(GenerationKey(priceList), ct) ?? "0";

    private static string GenerationKey(string priceList) => $"price-generation:{priceList}";
}

Discussion

  • Be the first to comment on this lesson.