Naive caching code — check the cache, miss, load from the database, write back — has a bug hiding in it: under load, a hundred requests can miss at the same time and all hit the database simultaneously, right when the cache expiry was supposed to be protecting it. HybridCache exists specifically to close that gap.

Key takeaways
  • HybridCache combines a fast in-process (L1) cache with a shared distributed (L2) cache like Redis behind a single GetOrCreateAsync API.
  • It coalesces concurrent requests for the same key into a single factory call — the cache stampede problem naive caching code has is handled for you.
  • Tag-based invalidation lets you remove groups of related entries without tracking every individual key.
  • Cache the genuinely expensive stuff — a slow query, an external API call — not everything. Caching cheap operations adds complexity without meaningful benefit.

Setup: L1 in memory, L2 in Redis

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

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

Reads hit the fast in-process L1 cache first; on an L1 miss, they check Redis (L2) before falling back to the factory. Writes populate both tiers, so other instances benefit from a warm L2 even before their own L1 fills up.

Using it

csharp
public class ProductCatalogService(HybridCache cache, IProductRepository repository)
{
    public async Task<Product?> GetProductAsync(Guid productId, CancellationToken ct)
    {
        return await cache.GetOrCreateAsync(
            $"product:{productId}",
            async token => await repository.GetAsync(productId, token),
            tags: ["products"],
            cancellationToken: ct);
    }
}

Under concurrent load, ten simultaneous requests for the same missing key result in one call to the factory delegate, not ten — the other nine wait for the first to complete and share its result. That's the stampede protection, and it's automatic.

Invalidating by tag

Tracking every cache key that needs to be invalidated when underlying data changes gets unwieldy fast. Tags let you invalidate a category at once.

csharp
public async Task UpdateProductAsync(Product product, CancellationToken ct)
{
    await repository.SaveAsync(product, ct);
    await cache.RemoveByTagAsync("products", ct);
}
Watch forL1 is per-instance in-process memory, which means it's naturally bounded by your app's memory and reset on restart — keep LocalCacheExpiration short relative to Expiration so instances don't serve meaningfully stale data relative to what L2 holds.

When you don't need HybridCache

A single-instance app with no shared cache requirement is often fine with plain IMemoryCache — simpler, no Redis dependency to operate. Reach for HybridCache once you're running multiple instances that need a consistent, shared cache view, or once cache stampedes have shown up as a real, measured problem rather than a theoretical one.

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.