Skip to main content
SEMastery
ASP.NETintermediate

HybridCache in ASP.NET Core: The New Caching Library Explained

Learn HybridCache in ASP.NET Core the simple way: two-level caching, stampede protection, tag invalidation, GetOrCreateAsync, and Redis setup with clear diagrams.

11 min readUpdated December 19, 2025

The shared fridge in a hostel kitchen

Imagine a hostel where many students share one kitchen. Each student has a small lunchbox at their own desk. That lunchbox is super fast to reach, but it is yours alone — nobody else can see what is inside.

The kitchen also has one big shared fridge. It is a little slower to walk to, but everyone in the hostel can use it. If one student stocks it with milk, every other student finds that milk waiting there.

Now picture the smartest student. When she wants milk, she first peeks in her own lunchbox. If it is there, great — instant. If not, she walks to the shared fridge. If the fridge has it, she takes some and also keeps a bit in her lunchbox for next time. Only if even the fridge is empty does she walk all the way to the shop, buy milk, and then refill both the fridge and her lunchbox.

That is exactly what HybridCache does. Your lunchbox is the fast in-memory cache (L1). The shared fridge is the distributed cache (L2), like Redis. The shop is your database. HybridCache checks the fast layer, then the shared layer, then the source — and refills as it goes. One simple helper does all of this for you.

What problem is HybridCache solving?

Before .NET 9, ASP.NET Core gave us two separate tools, and each had a weak side.

  • IMemoryCache is very fast because it stores objects right inside your app's memory. But it only lives on one server. If you run two or three copies of your app behind a load balancer, each copy has its own private cache. They do not share.
  • IDistributedCache fixes sharing. It stores bytes in something like Redis that all servers can read. But it is slower (a network hop), and you have to write the boring plumbing yourself: check the cache, serialize, deserialize, handle misses, and store again.

People kept writing the same glue code over and over. And almost nobody handled the cache stampede problem correctly. HybridCache, introduced in .NET 9 and now generally available, wraps both layers behind one clean API and solves the stampede problem for you.

Where HybridCache sits between your app and your data

How the two layers work together

When you ask HybridCache for a value by its key, it follows a clear order. It is the same order our smart student used in the hostel kitchen.

HybridCache lookup order

L1 memory
L2 Redis
Factory
Store back

Steps

1

L1 memory

Check fast in-process cache first

2

L2 Redis

If missed, check shared distributed cache

3

Factory

If still missed, run your data-fetch method once

4

Store back

Save the result into L2 and L1

The path a request takes from fastest to slowest

The big win is that you do not write this flow by hand. You give HybridCache a key and a factory (a small method that knows how to fetch the data). HybridCache decides whether it even needs to call your factory.

Step-by-step lookup with both cache layers

Installing and registering HybridCache

First, add the NuGet package. As of now it is shipped under the Microsoft.Extensions.Caching.Hybrid package.

dotnet add package Microsoft.Extensions.Caching.Hybrid

Then register it in your Program.cs. The simplest setup needs just one line.

var builder = WebApplication.CreateBuilder(args);
 
// Register HybridCache (uses in-memory L1 by default)
builder.Services.AddHybridCache();
 
var app = builder.Build();
app.Run();

That is enough to start. With only this, HybridCache uses its in-memory layer. If you never add a distributed cache, it simply runs as a smart, single-server cache with stampede protection — still a clear upgrade over raw IMemoryCache.

To add the shared layer, register a distributed cache too. HybridCache will pick it up automatically as L2.

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

You also need the Redis package for this:

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

Using GetOrCreateAsync — the one method you need

The heart of HybridCache is GetOrCreateAsync. You pass a key and a factory. HybridCache returns the cached value if it has one, or runs your factory once and caches the result.

public class ProductService(HybridCache cache, AppDbContext db)
{
    public async Task<Product?> GetProductAsync(int id, CancellationToken ct = default)
    {
        return await cache.GetOrCreateAsync(
            $"product:{id}",                // the cache key
            async token =>                  // the factory (only runs on a miss)
            {
                return await db.Products
                    .FirstOrDefaultAsync(p => p.Id == id, token);
            },
            cancellationToken: ct);
    }
}

Read that factory carefully. It only runs when the value is not already cached. On a hit, your database is never touched. Notice the token passed into the factory — HybridCache gives you a combined cancellation token, which matters for stampede protection (more on that next).

In an endpoint, you just call the service like normal:

app.MapGet("/products/{id}", async (int id, ProductService service, CancellationToken ct) =>
{
    var product = await service.GetProductAsync(id, ct);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

For a route like GET /products/{id}, the first call fills the cache and every call after that is served from memory or Redis until the entry expires.

Stampede protection: the quiet hero

Here is a problem that hits real systems hard. Suppose a popular product page is not cached yet — maybe it just expired. Now 500 users request it in the same second. With plain IMemoryCache, all 500 requests see a miss and all 500 hit the database at the same time. Your database can fall over. This is a cache stampede (also called the "thundering herd").

HybridCache handles this for you. For a given key, only one caller runs the factory. The other 499 callers wait for that single result and then share it. One database call instead of 500.

Stampede protection in action

500 requests
1 runs factory
499 wait
All get result

Steps

1

500 requests

All ask for the same missing key

2

1 runs factory

HybridCache lets only one fetch data

3

499 wait

Others pause for that single result

4

All get result

One DB hit, shared by everyone

Many callers, one factory run
Three callers, one factory call thanks to stampede protection

This is the single biggest reason to prefer HybridCache. You get this safety with zero extra code.

Controlling expiration with HybridCacheEntryOptions

You usually do not want data to live forever. HybridCache lets you set two timings per entry.

  • Expiration is the total lifetime across both layers (including L2/Redis).
  • LocalCacheExpiration is how long the value may stay in the fast L1 memory layer.
var options = new HybridCacheEntryOptions
{
    Expiration = TimeSpan.FromMinutes(10),          // overall lifetime
    LocalCacheExpiration = TimeSpan.FromMinutes(2)  // L1 memory lifetime
};
 
var product = await cache.GetOrCreateAsync(
    $"product:{id}",
    async token => await db.Products.FindAsync([id], token),
    options,
    cancellationToken: ct);

A short L1 time with a longer overall time is a common pattern. The memory copy stays fresh-ish, while Redis keeps a copy a bit longer so other servers benefit too.

Tags: invalidate many entries at once

Sometimes one change should clear many cached items. For example, when a product category changes, every product in it may need refreshing. Deleting keys one by one is painful. HybridCache lets you attach tags to entries and then wipe a whole tag in a single call.

var product = await cache.GetOrCreateAsync(
    $"product:{id}",
    async token => await db.Products.FindAsync([id], token),
    tags: ["products", $"category:{categoryId}"],
    cancellationToken: ct);

Later, when something in that category changes:

// Remove every entry tagged "category:42" in one shot
await cache.RemoveByTagAsync($"category:{categoryId}", ct);

You can also remove a single key directly:

await cache.RemoveAsync($"product:{id}", ct);

One honest caveat to remember: when you invalidate by key or by tag, the change applies to the current server and to the shared L2 store. The in-memory (L1) copies sitting on other servers are not instantly cleared — they simply expire on their own schedule. So keep your LocalCacheExpiration short if you need other servers to notice changes quickly.

Comparing the caching options

This table shows where HybridCache fits next to the older tools.

FeatureIMemoryCacheIDistributedCacheHybridCache
SpeedFastest (in-process)Slower (network)Fast L1 + shared L2
Shared across serversNoYesYes (via L2)
Stampede protectionNoNoYes (built in)
Tag invalidationNoNoYes
Serialization handledN/AManualAutomatic
One simple APIPartlyNoYes

And here is a quick guide on which method to reach for.

You want to...Use
Read with cache, fill on missGetOrCreateAsync
Store a value you already haveSetAsync
Remove one entry by keyRemoveAsync
Remove many entries by tagRemoveByTagAsync

Serialization: how objects become cache bytes

The L1 memory layer can keep your object as-is. But the L2 layer (Redis) only stores bytes. So HybridCache must turn your object into bytes and back. By default it uses JSON for your own types and a simple serializer for strings and byte arrays.

If you need something faster or smaller, you can plug in your own serializer, such as Protobuf. You register it when calling AddHybridCache.

builder.Services.AddHybridCache()
    .AddSerializer<MyDto, MyProtobufSerializer>();

For most apps the default JSON is perfectly fine. Reach for a custom serializer only when measurements show it matters.

How a value moves between your app, memory, and Redis

A few good habits

  • Pick clear, unique keys. Use a prefix and an id, like product:42. This avoids accidental collisions.
  • Cache the right things. Data that is read a lot and changes rarely is the best fit — reference lists, product details, settings.
  • Do not cache user secrets carelessly. A shared cache like Redis is visible to all servers; be thoughtful about what you put there.
  • Keep L1 short on multi-server setups so changes spread quickly enough for your needs.
  • Always pass the cancellation token through to your factory, so cancelled requests do not waste database work.

When HybridCache might be overkill

HybridCache is great, but you do not always need it. If your app runs on a single server and your data is tiny, plain IMemoryCache may be simpler. If you are caching whole HTTP responses rather than individual objects, output caching is often a better tool. HybridCache shines when you cache objects that are expensive to build and you run on more than one server — or when you simply want stampede protection without writing it yourself.

Note that some popular libraries in the .NET space have changed their licensing recently — for example, MediatR and MassTransit moved to commercial licenses. HybridCache is not one of those; it is part of the official Microsoft.Extensions packages and is free to use.

Quick recap

  • HybridCache (new in .NET 9) joins a fast in-memory layer (L1) and an optional shared distributed layer (L2, like Redis) behind one API.
  • The core method is GetOrCreateAsync: it returns cached data or runs your factory once on a miss, then stores the result in both layers.
  • Stampede protection is built in — for one key, only one caller runs the factory while the rest wait and share the result.
  • Tags let you invalidate many entries at once with RemoveByTagAsync; use RemoveAsync for a single key.
  • Set lifetimes with HybridCacheEntryOptions (Expiration for the whole entry, LocalCacheExpiration for the L1 copy).
  • It works without Redis; adding a distributed cache simply enables the shared L2 layer.
  • Remember the caveat: invalidation does not instantly clear L1 copies on other servers, so keep local expiration short when needed.

References and further reading

Related Posts