Start Debugging

HybridCache vs IMemoryCache vs IDistributedCache in .NET 11: which should you pick?

Default to HybridCache for new caching code in .NET 11. Reach for IMemoryCache only when you need raw single-server speed with no serialization, and IDistributedCache only as a backing store. Here is the decision matrix.

For new caching code in .NET 11, default to HybridCache. It gives you the in-process speed of IMemoryCache, the cross-server reach of IDistributedCache, and stampede protection plus tag invalidation that neither of the older APIs has, all behind one GetOrCreateAsync call. Reach for raw IMemoryCache only when you need single-server latency with zero serialization and fine-grained eviction control, and reach for raw IDistributedCache mainly when you need a distributed store without an L1 tier (or as HybridCache’s backing layer). This post backs that recommendation with the full feature matrix, the API differences that actually bite, and the gotcha that picks for you.

Everything here targets .NET 11, ASP.NET Core 11, and C# 14. HybridCache ships in the Microsoft.Extensions.Caching.Hybrid package, which went GA alongside .NET 9 and is the same package you use on .NET 11. It supports runtimes down to .NET Framework 4.7.2 and .NET Standard 2.0, so the comparison below is not limited to the newest TFM.

The feature matrix

FeatureIMemoryCacheIDistributedCacheHybridCache
TierL1 (in-process)L2 (out-of-process)L1 + optional L2
Shared across serversNoYesYes (via L2)
Survives process restartNoYesL2 survives, L1 does not
Stored aslive objectbyte[]object in L1, serialized in L2
Serializationnoneyou write itbuilt-in (System.Text.Json + more)
Stampede protectionnonoyes
Tag-based invalidationnonoyes (RemoveByTagAsync)
Get-or-create in one callextension only, unguardednoyes (GetOrCreateAsync)
Per-entry expiration controlfullabsolute + slidingoverall + local (LocalCacheExpiration)
Built-in OpenTelemetry metricsyes (.NET 11)depends on backendyes
In box (no NuGet)yesabstraction yes, backends nono (one package)
Min runtimebroadbroad.NET Framework 4.7.2 / netstandard2.0

All three are registered through DI and resolved by interface (or, for HybridCache, an abstract class). The differences that matter are not in registration, they are in what each one does on a cache miss and under concurrency.

What each API actually is

IMemoryCache stores live object references in a ConcurrentDictionary-backed store inside your process. There is no serialization: you put a Customer in, you get the same Customer reference out. That makes it the fastest of the three and the only one where a cache hit costs essentially a dictionary lookup. The cost is that it is per-process: two instances behind a load balancer have two independent caches, and a restart empties it.

// .NET 11, C# 14
builder.Services.AddMemoryCache();

public class ProductService(IMemoryCache cache, ProductDb db)
{
    public Task<Product> GetAsync(int id) =>
        cache.GetOrCreateAsync($"product:{id}", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return db.LoadProductAsync(id);
        })!;
}

IDistributedCache is a deliberately low-level abstraction over an out-of-process store. Its surface is GetAsync, SetAsync, RefreshAsync, and RemoveAsync (plus synchronous variants), and every value is a byte[]. There is no GetOrCreate, no object model, and no concurrency control. You own serialization, key naming, expiration policy, and the read-through pattern.

// .NET 11, C# 14
builder.Services.AddStackExchangeRedisCache(o =>
    o.Configuration = builder.Configuration.GetConnectionString("Redis"));

public class ProductService(IDistributedCache cache, ProductDb db)
{
    public async Task<Product> GetAsync(int id)
    {
        var key = $"product:{id}";
        var bytes = await cache.GetAsync(key);
        if (bytes is not null)
            return JsonSerializer.Deserialize<Product>(bytes)!;

        var product = await db.LoadProductAsync(id);
        await cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(product),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
            });
        return product;
    }
}

That is roughly fifteen lines of boilerplate per cached value, and every copy of it is a chance to forget expiration, mishandle a null, or pick a slightly different serializer. The built-in implementations include in-memory (AddDistributedMemoryCache, for dev and tests only, since it is not actually distributed), Redis (AddStackExchangeRedisCache), SQL Server (AddDistributedSqlServerCache), Azure Cache for Redis, and third-party stores such as NCache.

HybridCache is the abstraction Microsoft added to collapse the two patterns above into one. It keeps an in-process L1 (a MemoryCache by default) and, if you have registered an IDistributedCache, uses it automatically as the L2. A GetOrCreateAsync call checks L1, then L2, then runs your factory and writes back to both tiers. You never touch serialization unless you want to.

// .NET 11, C# 14
builder.Services.AddHybridCache();
// If an IDistributedCache is also registered, it becomes the L2 automatically.

public class ProductService(HybridCache cache, ProductDb db)
{
    public ValueTask<Product> GetAsync(int id, CancellationToken ct = default) =>
        cache.GetOrCreateAsync(
            $"product:{id}",
            async token => await db.LoadProductAsync(id, token),
            cancellationToken: ct);
}

Same outcome as the IDistributedCache block, three lines instead of fifteen, with stampede protection and an L1 tier you did not have to wire up.

When to pick IMemoryCache directly

The catch you accept by going direct: GetOrCreateAsync on IMemoryCache is an extension method with no stampede guard. Under a cold-cache burst, every concurrent caller runs the factory.

When to pick IDistributedCache directly

When to pick HybridCache (the default)

The stampede benchmark, concretely

This is the difference that shows up in production, so it is worth measuring rather than asserting. Take a factory that simulates a 200 ms database read and fire 100 concurrent GetOrCreateAsync calls for the same key against a cold cache.

// .NET 11, BenchmarkDotNet 0.15.x style harness (simplified)
async Task<int> Factory(CancellationToken _)
{
    Interlocked.Increment(ref _factoryCalls);
    await Task.Delay(200);          // stand-in for a DB / HTTP round trip
    return 42;
}

var tasks = Enumerable.Range(0, 100)
    .Select(_ => hybrid.GetOrCreateAsync("k", Factory).AsTask());
await Task.WhenAll(tasks);

With HybridCache, _factoryCalls is 1: one caller runs the 200 ms factory and the other 99 await its result, so the whole burst clears in roughly 200 ms with a single backing call. Swap in the IMemoryCache GetOrCreateAsync extension and _factoryCalls is up to 100, because nothing serializes the cold-miss callers. Against a real database that is the difference between one query and a hundred-deep connection-pool pileup. The exact count for the IMemoryCache case varies with timing (some callers may land after the first write completes), which is precisely the point: it is unbounded and non-deterministic, whereas HybridCache pins it at one. Numbers measured on .NET 11 (11.0.x), Windows 11, with the in-box L1 only and no L2 configured.

Expiration: the option names differ in a way that bites

The three APIs name expiration differently, and mixing them up is the most common configuration bug.

IMemoryCache uses MemoryCacheEntryOptions with AbsoluteExpiration, AbsoluteExpirationRelativeToNow, and SlidingExpiration. IDistributedCache uses DistributedCacheEntryOptions with the same three names. HybridCache uses HybridCacheEntryOptions with two properties that mean something different:

// .NET 11, C# 14
var options = new HybridCacheEntryOptions
{
    Expiration = TimeSpan.FromMinutes(5),        // overall lifetime (drives L2)
    LocalCacheExpiration = TimeSpan.FromMinutes(1) // how long the L1 copy is trusted
};

Expiration is the total lifetime of the entry, and it governs the L2 copy. LocalCacheExpiration is how long the in-process L1 copy is considered valid before the entry is re-fetched from L2. Setting LocalCacheExpiration shorter than Expiration is how you bound L1 staleness in a multi-server deployment: each node trusts its local copy for at most a minute, then revalidates against the shared L2. There is no sliding-expiration concept in HybridCache; if you depend on sliding windows, that is a reason to stay on the lower-level API.

Other defaults worth knowing: HybridCacheOptions.MaximumPayloadBytes defaults to 1 MB and MaximumKeyLength to 1024 characters. Values or keys over the limit are logged and silently not cached, which is a quiet failure mode if you cache large blobs.

The gotcha that picks for you

Tag and key invalidation in HybridCache does not reach the L1 of other servers. When you call RemoveByTagAsync or RemoveAsync, the entry is removed from the local L1 and from the shared L2, but every other node keeps serving its own L1 copy until that copy expires on its own LocalCacheExpiration. The docs are explicit: tag invalidation is a logical operation that marks future reads as misses, it does not actively purge other nodes.

That single behavior decides several designs:

The other forcing function is serialization safety. HybridCache deserializes a fresh object per caller by default to preserve IDistributedCache’s thread-safety guarantees. If your cached type is immutable, you can opt into instance reuse by sealing the type and applying [ImmutableObject(true)], which removes per-call deserialization overhead. If your cached objects are mutable and shared, do not apply that attribute, or you will introduce data races.

The recommendation, restated

In .NET 11, write new caching code against HybridCache unless you have a specific reason not to. It is a near drop-in for both older APIs, it eliminates the cache-aside boilerplate that IDistributedCache forces on you, and it closes the stampede hole that unguarded IMemoryCache.GetOrCreateAsync leaves open. Drop to raw IMemoryCache when you need single-server speed, zero serialization, or eviction features (size limits, priority, eviction callbacks) that HybridCache does not expose. Drop to raw IDistributedCache when you need a shared store with no L1 staleness window, when the serialized bytes are a contract with another system, or when you are using it for session and key storage rather than caching. For everything in between, which is most caching, HybridCache is the answer.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back