Every .NET developer has written this code at least once: check the cache, miss, fetch from the database, serialise, store in the distributed cache, return. Then you realise two hundred concurrent requests just called the same factory method simultaneously because the cache entry expired, and your database is now having a very bad time. You sprinkle in a SemaphoreSlim, add some byte[] deserialisation logic, and suddenly your "simple caching layer" is sixty lines of brittle ceremony.

HybridCache is Microsoft's answer to this pattern. Shipped as GA in the Microsoft.Extensions.Caching.Hybrid package, it combines an in-process L1 cache with an optional distributed L2 backend, wraps the entire cache-aside pattern in a single method call, and handles stampede protection out of the box. If you have been writing manual IDistributedCache plumbing, this is the replacement you have been waiting for.

The problem with IDistributedCache

The IDistributedCache interface has served .NET developers since ASP.NET Core 1.0, but it has never been pleasant to use directly. A typical cache-aside implementation looks something like this:

Services/ProductService.cs
public class ProductService(IDistributedCache cache, IProductRepository repo)
{
    public async Task<Product?> GetProductAsync(int productId, CancellationToken ct)
    {
        var key = $"product:{productId}";
        var cached = await cache.GetAsync(key, ct);

        if (cached is not null)
        {
            return JsonSerializer.Deserialize<Product>(cached);
        }

        var product = await repo.FindByIdAsync(productId, ct);

        if (product is not null)
        {
            var bytes = JsonSerializer.SerializeToUtf8Bytes(product);
            await cache.SetAsync(key, bytes, new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
            }, ct);
        }

        return product;
    }
}

This works, but there are several problems lurking beneath the surface. Serialisation is manual and repeated everywhere. There is no protection against cache stampedes: if a hundred requests arrive while the entry is expired, all of them will call FindByIdAsync. There is no in-process caching layer, so every single read hits the distributed cache over the network. And invalidating related entries requires tracking individual keys.

Getting started with HybridCache

Registration is a single line:

Program.cs
builder.Services.AddHybridCache();

That gives you a fully functional HybridCache with in-memory L1 storage. The entire cache-aside pattern from the previous example collapses into:

Services/ProductService.cs
public class ProductService(HybridCache cache, IProductRepository repo)
{
    public async Task<Product?> GetProductAsync(int productId, CancellationToken ct)
    {
        return await cache.GetOrCreateAsync(
            $"product:{productId}",
            async cancel => await repo.FindByIdAsync(productId, cancel),
            cancellationToken: ct
        );
    }
}

GetOrCreateAsync handles the complete lifecycle: it checks the L1 in-memory cache first, falls back to the L2 distributed cache if one is configured, and only calls your factory method on a full miss. The result is then stored in both layers automatically. Serialisation is handled internally using System.Text.Json by default.

How the two-tier architecture works

HybridCache operates across two layers:

When GetOrCreateAsync is called, it first checks L1. On a hit, it returns immediately. On a miss, it checks L2. If L2 has the data, it deserialises it, populates L1, and returns. Only if both layers miss does it invoke the factory method, storing the result in both L1 and L2.

If you do not register an IDistributedCache, HybridCache still functions perfectly well as a single-tier in-process cache with stampede protection — a meaningful upgrade over raw IMemoryCache on its own.

Adding a distributed backend

To add Redis as your L2 cache, register it alongside HybridCache:

Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
});

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(5),
        LocalCacheExpiration = TimeSpan.FromMinutes(2)
    };
});

Notice the two separate expiration values. Expiration controls the L2 lifetime, while LocalCacheExpiration controls the L1 lifetime. A shorter L1 expiration means individual server instances refresh from the distributed cache more frequently, which is useful when you need tighter consistency across a cluster.

// TIP

Setting LocalCacheExpiration shorter than Expiration is a common pattern. It means each server's in-memory cache refreshes from Redis every two minutes, while the Redis entry itself lives for five minutes before a full factory call is needed.

Stampede protection

Cache stampedes happen when a popular cache entry expires and dozens (or hundreds) of concurrent requests all try to regenerate it simultaneously. This can overwhelm your database or downstream service.

HybridCache solves this by ensuring that only one concurrent caller executes the factory method for a given key. All other callers for the same key wait for that single execution to complete and receive the same result. The CancellationToken passed to GetOrCreateAsync represents the combined cancellation of all waiting callers.

Example.cs
// Even if 200 requests arrive at the same instant for the same product,
// only ONE call to the repository will be made.
return await cache.GetOrCreateAsync(
    $"product:{productId}",
    async cancel => await repo.FindByIdAsync(productId, cancel),
    cancellationToken: ct
);

// WARNING

Stampede protection operates within a single process. If your service runs across multiple replicas, each replica may independently execute the factory method when the entry expires. For most workloads this is acceptable, but if you need cluster-wide deduplication, you will need an external distributed lock.

Tag-based invalidation

One of the most welcome additions — available since .NET 10 — is tag-based cache invalidation. Instead of tracking individual cache keys for bulk removal, you assign tags when storing entries and invalidate entire groups at once.

Services/CatalogueService.cs
public class CatalogueService(HybridCache cache, ICatalogueRepository repo)
{
    public async Task<Product?> GetProductAsync(int productId, CancellationToken ct)
    {
        var tags = new List<string> { "catalogue", $"category:{categoryId}" };
        var options = new HybridCacheEntryOptions
        {
            Expiration = TimeSpan.FromMinutes(10)
        };

        return await cache.GetOrCreateAsync(
            $"product:{productId}",
            async cancel => await repo.FindByIdAsync(productId, cancel),
            options,
            tags,
            cancellationToken: ct
        );
    }

    public async Task InvalidateCategoryAsync(int categoryId)
    {
        // Removes all entries tagged with this category
        await cache.RemoveByTagAsync($"category:{categoryId}");
    }

    public async Task InvalidateEntireCatalogueAsync()
    {
        // The wildcard "*" invalidates ALL HybridCache entries
        await cache.RemoveByTagAsync("*");
    }
}

It is worth understanding how tag invalidation works under the hood. Neither IMemoryCache nor IDistributedCache natively supports tags, so RemoveByTagAsync is a logical operation — it marks entries with those tags as stale rather than physically deleting them from the cache stores. Subsequent reads for tagged entries will be treated as cache misses, and the actual values expire naturally based on their configured lifetimes.

// NOTE

The asterisk * is reserved as a wildcard. Calling RemoveByTagAsync("*") invalidates every HybridCache entry, even those without tags. Glob-style patterns like "foo*" are not supported.

Configuring serialisation

By default, HybridCache uses System.Text.Json for everything except string and byte[], which are handled internally. For high-throughput scenarios where JSON overhead matters, you can plug in alternative serialisers like Protobuf:

Program.cs
builder.Services.AddHybridCache()
    .AddSerializer<Product, ProtobufSerializer<Product>>();

Or register a factory for all types that implement a shared interface:

Example.cs
builder.Services.AddHybridCache()
    .AddSerializerFactory<ProtobufSerializerFactory>();

Optimising for immutable types

By default, HybridCache deserialises a fresh copy of the cached object for each caller, mirroring the behaviour of IDistributedCache. This is safe because each caller gets an independent instance that they cannot accidentally mutate.

If your cached type is immutable, you can opt into reference reuse, which skips deserialisation entirely for L1 hits and returns the same object instance:

Models/ProductSummary.cs
[ImmutableObject(true)]
public sealed class ProductSummary
{
    public required int Id { get; init; }
    public required string Name { get; init; }
    public required decimal Price { get; init; }
}

Both conditions are required: the type must be sealed and decorated with [ImmutableObject(true)]. When both are present, L1 reads become zero-allocation — no deserialisation, no copying, just a reference return.

// WARNING

If you mark a type as immutable but your code actually mutates the returned instance, you will introduce subtle concurrency bugs. Every caller shares the same object reference.

Reducing byte[] allocations with IBufferDistributedCache

The standard IDistributedCache interface works with byte[] arrays, which means every L2 read allocates a new array. HybridCache can work with IBufferDistributedCache instead, an extension interface that uses IBufferWriter<byte> and ReadOnlySequence<byte> to avoid these allocations.

The Redis, SQL Server, and PostgreSQL caching packages all implement this interface. If you are using one of these backends, HybridCache automatically detects and uses the buffer-based API — no extra configuration needed.

The SetAsync and RemoveAsync methods

While GetOrCreateAsync covers most scenarios, HybridCache also provides explicit methods for when you need more control:

Example.cs
// Store a value directly without the get-or-create pattern
await cache.SetAsync($"product:{product.Id}", product, options, tags);

// Remove a specific entry by key (from both L1 and L2)
await cache.RemoveAsync($"product:{productId}");

// Remove multiple keys at once
await cache.RemoveAsync([$"product:{id1}", $"product:{id2}"]);

SetAsync is useful when you have already fetched or computed a value — for example, after a write operation where you know the new state and want to update the cache proactively.

Common pitfalls

Forgetting that L1 invalidation is local only. When you call RemoveAsync or RemoveByTagAsync, the entry is removed from the current server's L1 cache and from L2. Other servers' L1 caches are not notified. They will continue serving the stale L1 entry until LocalCacheExpiration elapses. Keep this in mind when setting your L1 lifetime.

Using long L1 expiration with frequent writes. If your data changes often, a long LocalCacheExpiration means servers in a cluster may serve stale data for an extended period. For write-heavy scenarios, consider a short L1 expiration (30 seconds to 2 minutes) or even disabling L1 caching for specific entries.

Not using the state overload for hot paths. The simple GetOrCreateAsync lambda captures variables, which allocates a closure. For extremely hot paths, use the overload that accepts a state tuple:

Example.cs
// Good — avoids closure allocation on hot paths
return await cache.GetOrCreateAsync(
    $"product:{productId}",
    (productId, repo),
    static async (state, cancel) =>
        await state.repo.FindByIdAsync(state.productId, cancel),
    cancellationToken: ct
);

Ignoring key length limits. Keys longer than 1024 characters (the default MaximumKeyLength) silently bypass the cache. If you are constructing composite keys from user input or long identifiers, validate the length or increase the limit.

Using user input directly in cache keys. Never use raw, unsanitised user input as part of a cache key. This opens the door to cache poisoning or denial-of-service through key flooding. Always use trusted, internally-generated identifiers.

Native AOT considerations

If you are targeting Native AOT, be aware that System.Text.Json reflection-based serialisation is not available. You will need to use source-generated serialisers:

Serialisation/AppJsonContext.cs
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(ProductSummary))]
internal partial class AppJsonContext : JsonSerializerContext;

Register the source-generated context when configuring HybridCache, and ensure all cached types are included in the context to prevent trimming from removing them.

Summary

The package is Microsoft.Extensions.Caching.Hybrid, and if you are still hand-rolling IDistributedCache wrappers, now is the time to stop.