Skip to main content
SEMastery
ASP.NETintermediate

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.

12 min readUpdated October 20, 2025

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

Counter tray
Storeroom
Loudspeaker

Steps

1

Counter tray

Fast local in-memory cache (L1)

2

Storeroom

Shared Redis cache and database (L2)

3

Loudspeaker

Backplane that announces changes

Each part of the shop maps to a caching idea.

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.

How a read flows through L1, then L2, then the database.

Two big bonus features

HybridCache also handles two hard things for you:

  1. 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.
  2. Tag-based invalidation. You can label entries with tags and later clear all entries that share a tag in a single call.
ConcernPlain IMemoryCachePlain IDistributedCacheHybridCache
Fast local readsYesNoYes (L1)
Shared across serversNoYesYes (L2)
Stampede protectionNoNoYes
Tag invalidationNoNoYes
Single simple APIPartlyPartlyYes

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).

A write updates the database, then clears tagged cache entries.

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.

Server A clears the cache but B and C keep stale L1 copies.

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 Redis

With 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

L1 check
L2 check
Factory

Steps

1

L1 check

Local memory, fastest

2

L2 check

Shared Redis, still fast

3

Factory

Hit DB only on full miss

Order of lookups when L2 is present.

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:

ApproachHow it worksWhen to pick it
Short L1 expirationKeep LocalCacheExpiration small (seconds)Simple apps that tolerate brief staleness
Redis pub/sub backplanePublish an invalidation message; each server clears its L1You need fast, cluster-wide consistency
FusionCache backplaneA drop-in library that adds a backplane over HybridCacheYou 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.

A Redis pub/sub backplane tells every server to drop stale L1 entries.

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

DB write
Local clear
Broadcast
Peers clear

Steps

1

DB write

Save the new value

2

Local clear

RemoveByTagAsync on this server

3

Broadcast

Publish tag to Redis channel

4

Peers clear

Every server drops stale L1

The complete path for a safe update across servers.

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 exampleStaleness allowed?Best strategy
Product priceNoActive invalidation by tag
Stock countNoActive invalidation by tag
Blog post textA littleShort expiration
Country listYesLong 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:5 for 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 LocalCacheExpiration limits 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 GetOrCreateAsync API.
  • 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

Related Posts