Solving Distributed Cache Invalidation with Redis and HybridCache
Learn how Redis and HybridCache solve distributed cache invalidation in ASP.NET Core with tags, backplanes, and a simple kitchen-counter analogy.
Caching makes apps fast. But the moment you keep a copy of data, you have a new problem: what happens when the real data changes? The copy is now wrong, and your users see old information. Cleaning up those stale copies at the right time, in the right places, is called cache invalidation. It is famously one of the trickiest jobs in software. This article shows how Redis and the HybridCache library in ASP.NET Core work together to make it manageable.
A kitchen-counter story
Imagine a busy sweet shop. The owner keeps a small tray of popular sweets on the front counter so customers get served fast. That tray is like a fast local cache. The big storeroom at the back is the real source of truth.
Now picture three counters in three corners of the shop, each with its own little tray. When the price of a sweet changes, the owner updates the storeroom. But every counter still has the old price on its tray. Unless someone walks to each counter and fixes the tray, customers at different counters get different prices. That is exactly the distributed cache invalidation problem. Many local trays, one storeroom, and no easy way to tell every tray "this item changed."
HybridCache gives us those local trays. Redis gives us the storeroom plus a loudspeaker to announce changes to every counter at once.
The sweet shop, mapped to caching
Steps
Counter tray
Fast local in-memory cache (L1)
Storeroom
Shared Redis cache and database (L2)
Loudspeaker
Backplane that announces changes
What HybridCache actually is
HybridCache is a caching library that shipped as part of the Microsoft.Extensions.Caching.Hybrid package. It became generally available with .NET 9 and is the recommended starting point for new .NET 10 projects. It blends two layers into one simple API:
- L1: a very fast in-memory cache inside your app process.
- L2: an optional out-of-process cache like Redis, shared by all your servers.
When you ask for data, HybridCache checks L1 first. If it is not there, it checks L2. If it is still not there, it runs your code to fetch the data (say, from the database), then saves the result into both layers for next time.
Two big bonus features
HybridCache also handles two hard things for you:
- Stampede protection. If 500 requests ask for the same missing key at once, a plain cache would run the database query 500 times. HybridCache runs it once and shares the result. This is sometimes called "cache stampede" or "thundering herd" protection.
- Tag-based invalidation. You can label entries with tags and later clear all entries that share a tag in a single call.
| Concern | Plain IMemoryCache | Plain IDistributedCache | HybridCache |
|---|---|---|---|
| Fast local reads | Yes | No | Yes (L1) |
| Shared across servers | No | Yes | Yes (L2) |
| Stampede protection | No | No | Yes |
| Tag invalidation | No | No | Yes |
| Single simple API | Partly | Partly | Yes |
Getting started
First add the package and register HybridCache in Program.cs. With no extra setup, it uses only L1 (in-memory).
// dotnet add package Microsoft.Extensions.Caching.Hybrid
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // total lifetime in L2
LocalCacheExpiration = TimeSpan.FromMinutes(2) // shorter L1 lifetime
};
});
var app = builder.Build();Now you can inject HybridCache and read data. The GetOrCreateAsync method is the heart of the library. You give it a key and a factory. If the value is missing, it runs the factory, stores the result, and returns it.
public sealed 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 => await db.Products.FindAsync([id], token),
tags: ["products", $"product:{id}"], // tags for later invalidation
cancellationToken: ct);
}
}Notice the tags argument. We tagged this entry with both products (every product) and product:{id} (this one product). Tags are the key to clean invalidation, which we cover next. Note that in prose a literal product:{id} must sit inside backticks, but inside the code block above the braces are perfectly fine.
The invalidation problem, step by step
Say a shopkeeper edits the price of product 42. After the database update, the cached copies are wrong. We need to clear them. With one server it is simple:
public async Task UpdatePriceAsync(int id, decimal newPrice, CancellationToken ct)
{
var product = await db.Products.FindAsync([id], ct);
product!.Price = newPrice;
await db.SaveChangesAsync(ct);
// Clear every entry tagged for this product.
await cache.RemoveByTagAsync($"product:{id}", ct);
}RemoveByTagAsync removes matching entries from both L1 and L2 on the server that runs it. There is also RemoveAsync for clearing a single exact key. And calling RemoveByTagAsync("*") logically invalidates everything, since * is a reserved wildcard tag (you cannot assign * to an entry yourself).
Where it gets hard: many servers
Real apps run on more than one server behind a load balancer. Each server has its own L1 memory. When server A clears the cache, it clears its own L1 and the shared L2 (Redis). But server B and server C still hold old data in their private L1 trays. They have no idea anything changed.
This is the core distributed cache invalidation problem. The fix is a backplane: a shared channel that announces "this tag or key was cleared" to every server so they all drop their stale L1 entries.
Adding Redis as the L2 backend
To share cache data across servers, register a Redis-backed IDistributedCache. HybridCache automatically uses it as L2.
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "shop:";
});
builder.Services.AddHybridCache(); // now L1 in-memory + L2 RedisWith Redis wired in, every server reads and writes the same L2 storeroom. A miss on one server can be filled from L2 instead of hitting the database again. This already removes a lot of duplicate work.
Read path with Redis as L2
Steps
L1 check
Local memory, fastest
L2 check
Shared Redis, still fast
Factory
Hit DB only on full miss
Closing the gap: the backplane
Sharing L2 is good, but it does not fix stale L1 copies on other servers. For that we need the loudspeaker from our sweet shop story. When any server invalidates a key or tag, it must broadcast a message so every other server drops the matching L1 entry.
At the time of writing, HybridCache's built-in backplane for broadcasting L1 invalidation across instances is an actively tracked feature in the dotnet/extensions repository. Until it lands fully, teams use one of these patterns:
| Approach | How it works | When to pick it |
|---|---|---|
| Short L1 expiration | Keep LocalCacheExpiration small (seconds) | Simple apps that tolerate brief staleness |
| Redis pub/sub backplane | Publish an invalidation message; each server clears its L1 | You need fast, cluster-wide consistency |
| FusionCache backplane | A drop-in library that adds a backplane over HybridCache | You want a battle-tested solution today |
A Redis backplane uses Redis pub/sub. When a node performs a write or removal, it also publishes a global invalidation message on a channel. Every other node is subscribed, receives the message, and removes the matching entry from its own L1.
Here is a small sketch of a hand-rolled backplane subscriber. In production you would likely lean on FusionCache instead, but seeing the moving parts helps.
public sealed class CacheBackplane(IConnectionMultiplexer redis, HybridCache cache)
{
private const string Channel = "cache-invalidation";
public async Task StartAsync()
{
var sub = redis.GetSubscriber();
await sub.SubscribeAsync(RedisChannel.Literal(Channel), async (_, tag) =>
{
// Another server cleared this tag; drop our local copy too.
await cache.RemoveByTagAsync(tag.ToString());
});
}
public async Task BroadcastAsync(string tag)
{
var sub = redis.GetSubscriber();
await sub.PublishAsync(RedisChannel.Literal(Channel), tag);
}
}Putting the full picture together
With all three pieces in place, an update flows like this: write to the database, clear the local and shared caches by tag, and broadcast the tag so every other server clears its L1 as well. Now all counters in the sweet shop show the same price.
Full invalidation flow
Steps
DB write
Save the new value
Local clear
RemoveByTagAsync on this server
Broadcast
Publish tag to Redis channel
Peers clear
Every server drops stale L1
When to invalidate versus when to expire
Not every change needs an instant broadcast. There are two honest strategies, and good apps mix them. The first is active invalidation: the moment data changes, you clear the matching cache entries so nobody ever sees the old value. This is what RemoveByTagAsync plus a backplane gives you. It is the right choice for things like account balances, stock levels, or prices, where being wrong even for a few seconds is a real problem.
The second is time-based expiration: you simply let entries grow old and disappear on their own after a set time. You accept that data can be slightly stale for that window. This is perfect for things that rarely change or where small staleness is harmless, like a list of countries, a blog post body, or a homepage banner. Expiration costs nothing extra to run, so prefer it whenever you can live with the delay.
| Data example | Staleness allowed? | Best strategy |
|---|---|---|
| Product price | No | Active invalidation by tag |
| Stock count | No | Active invalidation by tag |
| Blog post text | A little | Short expiration |
| Country list | Yes | Long expiration |
A simple rule of thumb: start with expiration because it is cheap and safe. Reach for active invalidation only for the handful of values where being out of date would actually hurt a user or the business. This keeps your backplane traffic small and your system easy to reason about.
A note on licensing
If you search for backplane helpers, you will meet libraries like FusionCache, which is free and open source and pairs nicely with HybridCache. Be aware, though, that some popular .NET libraries changed their terms recently. MediatR and MassTransit are now commercially licensed for many uses. They are not required for caching at all, but you may see them in older tutorials, so check the current license before you add any dependency to a real project.
Practical tips
- Tag thoughtfully. Group entries that change together, like
category:5for all products in a category. Avoid attaching huge numbers of tags to one entry, as large tag sets can hurt performance. - Keep L1 short, L2 longer. A short
LocalCacheExpirationlimits how long a server can be wrong if a message is missed. - Make factories cheap to retry. With stampede protection one factory call serves many waiters, so keep it correct and side-effect free.
- Tags cannot be empty, whitespace, or the reserved
*. Validate tag names you build from user input.
Quick recap
- Caching keeps copies of data for speed, and invalidation is the job of clearing those copies when the real data changes.
- HybridCache combines a fast in-memory L1 with an optional shared L2 (like Redis), behind one simple
GetOrCreateAsyncAPI. - It adds stampede protection and tag-based invalidation via
RemoveByTagAsync. - With many servers, clearing the cache on one server leaves stale L1 copies on the others. This is the distributed invalidation problem.
- Redis as L2 lets servers share cached data; a backplane (Redis pub/sub or FusionCache) tells every server to drop stale L1 entries.
- Keep L1 expirations short, tag entries that change together, and broadcast invalidations so all servers stay consistent.
References and further reading
- HybridCache library in ASP.NET Core (Microsoft Learn)
- Hello HybridCache! HybridCache is now GA (.NET Blog)
- Caching in .NET (Microsoft Learn)
- Solving the Distributed Cache Invalidation Problem with Redis and HybridCache (Milan Jovanovic)
- HybridCache tags and invalidation tracking issue (dotnet/extensions #7098)
- Using FusionCache's backplane to sync HybridCache instances (Tim Deschryver)
Related Posts
How to Implement Caching Strategies in .NET: A Beginner-Friendly Guide
Learn caching strategies in .NET with simple examples: in-memory cache, distributed Redis cache, and HybridCache, plus eviction, stampede protection, and tags.
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.
Make Your ASP.NET Core Web API 18x Faster with HybridCache
Learn how HybridCache in .NET 9 speeds up your ASP.NET Core Web API up to 18x, with L1/L2 caching, stampede protection, and tags, explained simply.
Caching in ASP.NET Core: Make Your App Fast (The Easy Way)
Learn caching in ASP.NET Core with simple examples. Understand in-memory cache, distributed Redis cache, HybridCache, and output cache, with diagrams, code, and clear advice on which to use and when.
Real-Time Server-Sent Events in ASP.NET Core and .NET 10
Learn Server-Sent Events (SSE) in ASP.NET Core and .NET 10 with the new TypedResults.ServerSentEvents API, explained simply for beginners.
ASP.NET Core Output Cache: Speed Up Your API with In-Memory and Redis
Learn ASP.NET Core output caching the easy way: cache whole API responses in memory or in Redis, set policies, vary by query, and clear with tags — with diagrams and code.