Distributed systems give you scale, but they also hand you a fresh set of headaches. Cache invalidation is one of the oldest problems in the book, and it has not gone away just because .NET 9 shipped a new caching abstraction called HybridCache. If you are running more than one instance of your app, you need to understand exactly where this abstraction stops helping you and where you need to step in yourself.
What HybridCache Actually Gives You
HybridCache combines two layers. The first is an in-memory L1 cache, which is fast because it lives in the same process as your code. The second is a distributed L2 cache, typically backed by Redis, which is shared across every instance of your application. On paper this sounds like the best of both worlds, and for a single-node deployment it genuinely is.
The catch shows up the moment you scale out. HybridCache does not synchronize the L1 layer across nodes. Each instance keeps its own local copy in memory, and there is no built-in mechanism that tells Node B to forget a key just because Node A updated it. This is a known gap, and it comes up regularly in discussions on the dotnet/extensions GitHub repository.
A Failure Scenario You Have Probably Hit Before
Picture a typical production setup: an API running behind a load balancer across two or more servers, each using HybridCache for speed. Here is how things go wrong without a fix in place.
- User A updates their profile, and the request lands on Server 1.
- Server 1 updates the database and clears its own local HybridCache entry.
- The next request from User A (or User B looking at the same profile) is routed to Server 2.
- Server 2 still has the old profile sitting in its local L1 cache, because nobody told it to evict that key.
- The user sees stale data, even though the database has the correct value.

This is not a rare edge case. Any application with more than one instance and any mutable data that is cached locally will run into this sooner or later. Permissions, feature flags, and pricing are the usual suspects, since users notice stale values in these fields almost immediately.
Why Shortening the TTL Is Not a Real Fix
A common workaround is to reduce the local cache duration, say to ten seconds, so that stale data does not linger for too long. It feels like a quick patch, and it is tempting because it requires no new infrastructure. But it only narrows the window of inconsistency. It does not close it.
- Increased latency: your app now hits Redis (or the database) far more often, since the local cache expires before it has done much good.
- Lost efficiency: the entire point of an L1 cache is to skip network calls altogether. Aggressive expiry quietly undoes that benefit.
For read-heavy data where a few seconds of staleness genuinely does not matter, such as a product catalog listing or a blog post view count, shortening the TTL is a fine trade-off. But for anything where correctness matters the moment a change happens, this approach just masks the problem instead of solving it.
The Fix: A Redis Pub/Sub Backplane
What you actually need is a backplane: a communication channel connecting every node running your application. When one node removes or updates a cache entry, it publishes a message on this channel. Every other node is subscribed, and on receiving that message, evicts the same key from its own local cache.
Since Redis is already doing duty as your L2 cache, using its Pub/Sub feature for this signaling is a natural fit. You do not need to stand up a separate message queue just for cache coordination.
- Publisher: the node making the change publishes an invalidation message containing the cache key.
- Subscriber: every node in the cluster listens on the same channel.
- Action: on receiving a message, each node calls HybridCache.RemoveAsync(key) to clear its local copy.

The mechanics are simple, and that simplicity is exactly why this pattern has stayed popular over the years even as caching libraries have evolved around it.
Building the Invalidation Publisher
Start with a small abstraction that wraps the Redis publish call. This keeps the rest of your codebase from depending directly on StackExchange.Redis, which makes it easier to swap implementations later if you need to.
public interface ICacheInvalidator
{
Task InvalidateAsync(string key, CancellationToken cancellationToken = default);
}
public class RedisCacheInvalidator(
IConnectionMultiplexer connectionMultiplexer,
ILogger<RedisCacheInvalidator> logger)
: ICacheInvalidator
{
private const RedisChannel Channel = RedisChannel.Literal("cache-invalidation");
public async Task InvalidateAsync(string key, CancellationToken cancellationToken = default)
{
var subscriber = connectionMultiplexer.GetSubscriber();
await subscriber.PublishAsync(Channel, new RedisValue(key));
logger.LogInformation("Published invalidation for key: {Key}", key);
}
}
This class does one job: it takes a cache key and broadcasts it on a fixed Redis channel named cache-invalidation. Note the use of RedisChannel.Literal, which tells StackExchange.Redis to treat the channel name as an exact string rather than a pattern. Get this wrong and you can end up with subtle bugs where subscribers silently fail to match, because pattern-mode channels use glob-style matching instead of exact comparisons.
Wiring Invalidation Into Your Command Handlers
With the publisher ready, call it right after you commit a change to the database. This is the point in your code where you know for certain that the underlying data has changed and any cached copy is now wrong.
public class UpdateUserProfileHandler(
AppDbContext dbContext,
ICacheInvalidator cacheInvalidator,
ILogger<UpdateUserProfileHandler> logger)
{
public async Task Handle(int userId, string newName, CancellationToken ct)
{
// 1. Update the database
var user = await dbContext.Users.FindAsync([userId], ct);
if (user is null)
{
return;
}
user.Name = newName;
await dbContext.SaveChangesAsync(ct);
// 2. Invalidate the cache (Distributed)
var cacheKey = $"user:{userId}";
await cacheInvalidator.InvalidateAsync(cacheKey, ct);
logger.LogInformation("Updated user and invalidated cache for {UserId}", userId);
}
}
Nothing unusual here, and that is the point. You save to the database first, then invalidate. If you invalidate before the database write commits, a request that lands on another node in that narrow gap could re-populate the cache with the old value, and you would be back to square one. Ordering matters more than it looks like it does.
The Background Listener That Evicts Local Entries
Every node also needs a background service that stays subscribed to the same Redis channel for as long as the app is running. When a message arrives, it evicts the matching key from that node’s own HybridCache instance.
public class CacheInvalidationService(
IConnectionMultiplexer connectionMultiplexer,
HybridCache hybridCache,
ILogger<CacheInvalidationService> logger)
: BackgroundService
{
private const RedisChannel Channel = RedisChannel.Literal("cache-invalidation");
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var subscriber = connectionMultiplexer.GetSubscriber();
await subscriber.SubscribeAsync(Channel, (channel, value) =>
{
string key = value.ToString();
logger.LogInformation("Invalidating local cache for: {Key}", key);
// This removes the item from the local L1 cache
var task = hybridCache.RemoveAsync(key, stoppingToken);
if (!task.IsCompleted)
{
task.GetAwaiter().GetResult();
}
});
}
}
A couple of things worth flagging here. First, Redis Pub/Sub broadcasts to every subscriber, including the node that published the message in the first place. That node will call RemoveAsync on a key it has already cleared locally, which is harmless, just a bit redundant. Second, the callback signature StackExchange.Redis hands you for SubscribeAsync is synchronous, which is why the code falls back to task.GetAwaiter().GetResult() when the removal does not complete immediately. This works, but blocking inside a callback like this is worth watching in high-throughput scenarios, since it ties up a thread pool thread while waiting. If your Redis round trips are consistently fast this rarely bites you, but it is something to load-test rather than assume away. An alternative worth considering is injecting IMemoryCache directly instead of HybridCache, since IMemoryCache is the actual L1 store underneath and gives you a synchronous Remove call with no blocking involved.
Registering Everything in DI
The last piece is wiring these services into your dependency injection container at startup.
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect("<REDIS_CONNECTION_STRING>"));
// Register HybridCache (defaults generally work fine for L1)
builder.Services.AddHybridCache();
// Register our invalidation services
builder.Services.AddSingleton<ICacheInvalidator, RedisCacheInvalidator>();
builder.Services.AddHostedService<CacheInvalidationService>();
Once this is in place, calling InvalidateAsync(“user:123”) on Node A results in Redis pushing that message to Node B, Node C, and every other subscribed instance. Each one calls HybridCache.RemoveAsync(“user:123”) in response, so the next read on any node goes back to the database or the L2 cache and comes back with fresh data.
FusionCache as a Ready-Made Alternative
If hand-rolling a backplane feels like more plumbing than you want to own, FusionCache is worth a serious look before you commit to the approach above. It is a mature library that has solved exactly this problem for years, with a built-in backplane feature that handles the Pub/Sub wiring for you.
FusionCache has also added an implementation of the HybridCache abstract class, so you can adopt it without rewriting the calling code that already depends on HybridCache.
// Using FusionCache's implementation of HybridCache
builder.Services.AddFusionCache()
.WithBackplane(
new RedisBackplane(new RedisBackplaneOptions { Configuration = "<REDIS_CONNECTION_STRING>" }))
.AsHybridCache();
This one line replaces the publisher, the background listener, and the DI registrations you wrote by hand earlier. The trade-off is that you take on an extra dependency and give up some control over exactly how invalidation messages are structured and logged. For most teams building typical CRUD applications, that trade-off is worth it. For teams that already have strong opinions about their caching internals, or that want to fold cache invalidation events into an existing event bus, building it yourself as shown above keeps things more transparent.
Production Considerations Before You Ship This
Redis Pub/Sub is fire and forget. If a node is disconnected from Redis when a message is published, it never receives that invalidation, and it will keep serving a stale value from its L1 cache until that key’s own TTL eventually expires. This is an important limitation to accept going in. Pub/Sub gives you low latency invalidation in the common case, not a guaranteed delivery mechanism.
If your data genuinely cannot tolerate this gap, look at Redis Streams instead of Pub/Sub. Streams persist messages and support consumer groups with acknowledgement, so a node that reconnects after a blip can catch up on what it missed. It is more machinery to set up, but it closes the gap that plain Pub/Sub leaves open.
Also keep a reasonable L1 TTL as a safety net even after adding the backplane. Treat the backplane as your fast path for invalidation and the TTL as your backstop for the rare cases where a message gets dropped. Belt and suspenders is not overkill here, it is what makes the system self-healing.
Finally, test this with more than one instance running locally before you trust it in production. It is easy to write and unit test the publisher and subscriber in isolation and still miss a wiring mistake, such as a mismatched channel name between the two, that only shows up once multiple real instances are talking to the same Redis.
When This Pattern Is Overkill
If your application runs as a single instance, none of this applies. HybridCache’s L1 and L2 layers already do the right thing without any coordination, since there is only one L1 to worry about. Do not add a backplane just because it seems like the more sophisticated choice.
Even in a multi-node setup, plenty of cached data can tolerate a few seconds of staleness without anyone noticing or caring, things like product listings, article view counts, or dashboard aggregates. For that category, a short TTL genuinely is the simpler and correct answer, and building a backplane for it is unnecessary complexity. Reach for the Redis Pub/Sub backplane specifically for data where staleness causes real problems: permissions, feature flags, pricing, and similar fields where users and support tickets will surface the inconsistency almost immediately.
Wrapping Up
HybridCache is a genuinely useful addition to .NET, merging the roles that IMemoryCache and IDistributedCache used to play separately. But it stops short of solving cross-node consistency for you, and it is worth knowing that limitation before you ship a multi-instance deployment on top of it.
A Redis Pub/Sub backplane is a small, well understood way to close that gap yourself, and FusionCache is a solid option if you would rather not maintain that plumbing. Pick whichever fits your team’s appetite for extra dependencies, and pair either choice with a sensible L1 TTL so a missed message never turns into permanently stale data.
Leave a Reply