Steven's Knowledge

Caching

Caching in .NET -- IMemoryCache, IDistributedCache, Redis, and the new HybridCache, plus cache stampede protection and invalidation strategy

Caching

.NET offers a layered set of caching abstractions, from a process-local in-memory cache to a shared distributed cache, and -- new in .NET 9 -- HybridCache, which combines both and fixes the long-standing rough edges of doing it by hand. Choosing the right layer is the whole game. In-memory is the fastest thing you can do -- it stores live object references with no serialization at all -- but it is per-instance and lost on restart. A distributed cache is shared across every instance and survives restarts, but every hit pays a serialization and network cost. Most of the decisions on this page are really about which of those trade-offs you are willing to make for a given piece of data.

This is the .NET-specific how-to. The strategy theory -- cache-aside, write-through, TTLs, eviction policies -- lives on the language-agnostic caching page; here we map those ideas onto the concrete .NET types and the sharp edges each one has.

IMemoryCache (In-Process)

IMemoryCache is the fastest cache you have because it stores live object references directly on the heap -- no serialization, no copy, no network. The cost of that speed is that it is local to a single process. In a multi-instance deployment each node keeps its own copy, so two nodes can hold different values for the same key and disagree, and everything in the cache is gone on restart or redeploy. That makes it ideal for data that is expensive to compute, tolerant of a little staleness, and either identical on every node or genuinely per-node.

The idiomatic pattern is cache-aside through GetOrCreateAsync: ask the cache for the key, and if it is missing the factory runs, populates the entry, and returns the value.

public async Task<Product> GetProductAsync(string id, CancellationToken ct)
{
    return (await _cache.GetOrCreateAsync(id, async entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
        entry.SlidingExpiration = TimeSpan.FromMinutes(2);
        return await _store.GetAsync(id, ct);
    }))!;
}

The two expirations do different jobs. AbsoluteExpirationRelativeToNow is a hard ceiling -- the entry dies ten minutes after it was created no matter how often it is read, which bounds staleness. SlidingExpiration resets a shorter idle timer on every access, so frequently-read entries stay warm while cold ones fall out. Use them together: sliding to evict the unused, absolute to guarantee nothing is ever served past its staleness budget.

The detail people skip is size. By default IMemoryCache has no bound, and an unbounded cache keyed on anything user-controlled -- product IDs, search terms, tenant keys -- is a memory leak waiting to happen. Set a SizeLimit on the cache and a Size on every entry (via SetSize) so the cache can evict under pressure; if you set a limit but forget the per-entry size, writes throw. One more thing worth knowing: expiration is lazy. An expired entry is not proactively removed at the instant it expires -- it is evicted when something next touches that key, or when the background compaction scan runs. So "expired" means "will not be served", not "memory already freed".

IDistributedCache (Shared)

IDistributedCache is the shared tier -- usually Redis, sometimes SQL Server -- so every instance reads and writes the same data and the cache survives an app restart. That solves the two big weaknesses of IMemoryCache: nodes no longer disagree, and a redeploy does not cold-start your cache. The trade is that the interface is byte-oriented. It stores and returns byte[], so you serialize and deserialize yourself, almost always with System.Text.Json.

builder.Services.AddStackExchangeRedisCache(o =>
{
    o.Configuration = builder.Configuration.GetConnectionString("Redis");
});

With Redis registered, a cache-aside read becomes a get, a deserialize-on-hit, and a populate-on-miss. The serialization is yours to own -- which also means it is yours to get wrong.

public async Task<Product> GetProductAsync(string id, CancellationToken ct)
{
    var key = $"product:{id}";
    var cached = await _cache.GetStringAsync(key, ct);
    if (cached is not null)
        return JsonSerializer.Deserialize<Product>(cached)!;

    var product = await _store.GetAsync(id, ct);
    await _cache.SetStringAsync(key, JsonSerializer.Serialize(product),
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) },
        ct);
    return product;
}

Be honest about the costs. Every hit is a network round-trip plus a deserialize, so a distributed cache is meaningfully slower than in-memory -- fast enough to be worth it for an expensive query, not fast enough to wrap around something already cheap. And the cache is a dependency that can blink: a Redis failover, a network hiccup, or a saturated connection pool can make a call fail or time out. Do not let a cache outage take the request down with it. Treat the cache as an optimization, catch the failure, and fall back to the source of truth -- a slow request that succeeds beats a fast one that 500s because Redis sneezed.

HybridCache (.NET 9)

HybridCache is the new abstraction that layers an in-memory L1 in front of a distributed L2 and hands you a single GetOrCreateAsync API over both. It exists because almost everyone was hand-writing the same two-tier cache, and most of those hand-rolled versions were subtly wrong.

builder.Services.AddHybridCache();

public async Task<Product> GetProductAsync(string id, CancellationToken ct)
{
    return await _hybrid.GetOrCreateAsync(
        $"product:{id}",
        async token => await _store.GetAsync(id, token),
        new HybridCacheEntryOptions
        {
            Expiration = TimeSpan.FromMinutes(10),          // L2
            LocalCacheExpiration = TimeSpan.FromMinutes(2)  // L1
        },
        cancellationToken: ct);
}

It solves three problems you used to solve yourself. First, the two-level lookup: it checks the fast local cache, falls through to the shared distributed cache, and only then runs your factory against the source -- all of that orchestration is gone from your code. Second, stampede protection: when many concurrent requests miss the same key, HybridCache coalesces them so the factory runs once and everyone awaits that single result, instead of N callers all hammering the database. Third, it owns serialization for you and adds tag-based invalidation, so you no longer write the JSON dance from the IDistributedCache section by hand. Note that the two expiration values map cleanly onto the two tiers -- Expiration is the L2 lifetime, LocalCacheExpiration the shorter L1 one.

Make HybridCache the default for new services on .NET 9 and later. It replaces essentially all of the hand-written "in-memory in front of Redis, with a lock to stop stampedes" code that teams have been copy-pasting between projects for years, and it gets the hard parts right out of the box.

Cache Stampede

A cache stampede -- also called thundering herd or dogpile -- is the failure mode where a hot key expires and, in the same instant, N concurrent requests all miss, all run the factory, and all hit the database at once. The cache that was supposed to protect the database instead schedules a synchronized strike against it, and the busier the key, the worse the spike. The entry's popularity is exactly what makes its expiry dangerous.

With raw IMemoryCache or IDistributedCache you mitigate this yourself, classically with a per-key SemaphoreSlim so only one caller repopulates while the rest wait for it.

private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

public async Task<Product> GetProductAsync(string id, CancellationToken ct)
{
    if (_cache.TryGetValue<Product>(id, out var cached))
        return cached!;

    var gate = _locks.GetOrAdd(id, _ => new SemaphoreSlim(1, 1));
    await gate.WaitAsync(ct);
    try
    {
        if (_cache.TryGetValue(id, out cached))   // double-check after acquiring
            return cached!;

        var product = await _store.GetAsync(id, ct);
        _cache.Set(id, product, TimeSpan.FromMinutes(10));
        return product;
    }
    finally { gate.Release(); }
}

The double-check after acquiring the lock is the point: the first caller through repopulates, and everyone who was waiting finds the value already there and skips the database entirely. This works, but it is fiddly -- you are managing a dictionary of semaphores, deciding when to evict them, and reasoning about cancellation. This is exactly what HybridCache does natively, so on .NET 9 and later you stop writing this code by hand and let the framework coalesce for you.

Invalidation

Invalidation is the genuinely hard part of caching, and there is no single answer -- only a ladder of techniques with increasing cost and precision. The baseline is TTL: set an expiration and accept that data can be stale for up to that long. It needs no coordination and is right far more often than people expect; if you can tolerate a few minutes of staleness, a TTL alone may be the entire strategy.

When you cannot wait out a TTL, do explicit removal on write. In cache-aside, the code that updates the database also evicts the key (Remove / RemoveAsync) so the next read repopulates from fresh data. This is simple and correct on a single node -- and it has a nasty multi-instance gotcha. Calling Remove on the local IMemoryCache of the node that handled the write clears only that node's copy; every other instance keeps serving the stale value until its own TTL lapses. To invalidate across instances you need a tier they all share -- a distributed cache -- or a fan-out signal such as a Redis pub/sub message that tells each node to drop the key, or HybridCache, whose tagging handles cross-node invalidation for you.

await _hybrid.RemoveByTagAsync("product", ct);

For invalidating a whole class of entries at once, two tools stand out. Cache-key versioning bakes a version number into the key (product:v3:{id}); bump the version and every old key is instantly orphaned and falls out by TTL, with no enumeration and no race -- ideal after a schema or serialization change. And HybridCache's RemoveByTagAsync does group invalidation directly: tag related entries when you create them, then drop the whole group in one call when the underlying data changes.

Response Caching vs Output Caching

These two sound alike and solve different problems. HTTP response caching is about cache-control headers -- you set Cache-Control, Expires, and friends on the response, and you are asking clients and downstream proxies (browsers, CDNs, reverse proxies) to honor them. You do not store anything server-side; you delegate caching to infrastructure you do not fully control, and a misconfigured client simply ignores you.

ASP.NET Core output caching is server-side and under your control. You register it and opt endpoints in, and the server stores the rendered response and serves it directly on the next matching request without re-running the handler. It is the modern recommendation for caching whole endpoint responses, because you decide exactly what is cached, for how long, and how it varies.

builder.Services.AddOutputCache();
app.UseOutputCache();

app.MapGet("/products", GetAllProducts)
   .CacheOutput(p => p.Expire(TimeSpan.FromSeconds(30)));

Sharp Edges

The recurring failures cluster predictably. An unbounded IMemoryCache keyed on user-controlled values grows until the process is OOM-killed -- set a SizeLimit and a per-entry Size, always. Caching only positive results lets a stream of requests for a missing key sail past the cache and hammer the database every time, so cache the "not found" too, briefly, to absorb negative lookups. In a distributed cache the serialization is yours to pay for and yours to break: a deploy that changes a type's shape can leave old JSON in Redis that no longer deserializes, so version your payloads or your keys. Local invalidation does not cross instances -- removing from one node's IMemoryCache leaves every other node stale until its TTL, so reach for a shared tier, pub/sub, or HybridCache tags when correctness demands it. Hot keys stampede the moment they expire unless you coalesce, which is reason enough to default to HybridCache on .NET 9. And above all, decide your consistency budget before you cache anything -- how stale is too stale for this data? -- because every cache trades freshness for speed, and a cache you cannot reason about is just a bug with good latency.

On this page