In .NET, you have 6 ways to cache: IMemoryCache, IDistributedCache, Redis, HybridCache, FusionCache, and Output Cache.
This is the 2nd part of the Caching in .NET series.
Last week, in Part 1 we explored:
- Where Each Cache Sits in a .NET App
- In-Memory Cache (IMemoryCache) (Part 1)
- Distributed Cache (IDistributedCache) with Redis (Part 1)
- HybridCache (Part 1)
In this post, we will explore each caching option with pros, cons, pitfalls, and when to use it:
- FusionCache
- Output Cache
- Which Cache Should You Choose
Let's dive in.
FusionCache
FusionCache is an open-source hybrid cache that existed years before HybridCache did.
It has the same L1 + L2 shape and the same stampede protection. It also has what HybridCache lacks: a backplane that syncs L1 across instances, fail-safe, factory timeouts, and eager refresh.
FusionCache is the best caching option available in .NET today.
To get started with FusionCache, add these packages:
bashdotnet add package ZiggyCreatures.FusionCache dotnet add package ZiggyCreatures.FusionCache.Serialization.SystemTextJson dotnet add package ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
Then you can configure it in the DI container:
csharpvar redisConnection = builder.Configuration.GetConnectionString("Redis")!; builder.Services.AddFusionCache() .WithDefaultEntryOptions(new FusionCacheEntryOptions { Duration = TimeSpan.FromMinutes(10), IsFailSafeEnabled = true, FailSafeMaxDuration = TimeSpan.FromHours(1), FactorySoftTimeout = TimeSpan.FromMilliseconds(300) }) .WithSerializer(new FusionCacheSystemTextJsonSerializer()) .WithDistributedCache(new RedisCache(new RedisCacheOptions { Configuration = redisConnection })) .WithBackplane(new RedisBackplane(new RedisBackplaneOptions { Configuration = redisConnection }));
Three building blocks are registered here:
- Serializer for the L2 payload. System.Text.Json is the default choice.
- Distributed cache as L2. This is the same
RedisCachetype fromIDistributedCache. - Backplane as the channel that tells other instances about changes. It uses Redis pub/sub.
The handler uses IFusionCache:
csharpinternal sealed class GetShipmentByNumberHandler( ShipmentsDbContext dbContext, IFusionCache cache) { public async Task<ShipmentResponse?> HandleAsync( string shipmentNumber, CancellationToken cancellationToken) { return await cache.GetOrSetAsync<ShipmentResponse?>( $"shipment:{shipmentNumber}", async (_, ct) => await dbContext.Shipments .Where(s => s.Number == shipmentNumber) .Select(s => s.MapToResponse()) .FirstOrDefaultAsync(ct), tags: ["shipments"], token: cancellationToken); } }
You can remove items by key or a tag, exactly like in HybridCache:
csharpawait cache.RemoveAsync($"shipment:{shipmentNumber}", token: cancellationToken);
The difference is what happens after that call on the other app instances:
The backplane message carries only the key and the action without a value. Instance B drops its L1 (memory) copy, and the next read on B goes to L2 (Redis) and then to the database.
This is the direct fix for the biggest pitfall in HybridCache.
Using FusionCache as HybridCache
If your handlers already inject HybridCache, you don't have to rewrite them. FusionCache can register itself as the HybridCache implementation:
csharpbuilder.Services.AddFusionCache() .WithSerializer(new FusionCacheSystemTextJsonSerializer()) .WithDistributedCache(new RedisCache(new RedisCacheOptions { Configuration = redisConnection })) .WithBackplane(new RedisBackplane(new RedisBackplaneOptions { Configuration = redisConnection })) .AsHybridCache();
So you continue using HybridCache in your classes.
Note: through the HybridCache adapter you can only pass
HybridCacheEntryOptionsper call. Fail-safe, timeouts, and eager refresh are configured once in the default entry options.
What Else FusionCache Gives You
I will only name the features here, since this library deserves its own article:
- Fail-safe: when the database is down and the factory throws, FusionCache serves the last known value instead of an error.
- Soft and hard timeouts: if the factory takes longer than the soft timeout and a stale value exists, the stale value is returned and the factory keeps running in the background.
- Eager refresh: refresh an entry in the background before it expires, so hot keys never see a miss.
- Adaptive caching: the factory can set the TTL per entry based on what it loaded.
FusionCache has the following advantages:
- Backplane: L1 caches stay in sync across instances.
- Resilience features: fail-safe and timeouts keep your API answering when dependencies are slow.
- Drop-in for HybridCache: the
AsHybridCache()adapter keeps your handlers on the Microsoft abstraction. - Mature: in production since 2021, well documented, active maintainer.
Here are the drawbacks of FusionCache:
- Third-party dependency: one more package to track and upgrade.
- Bigger configuration surface: more options means more ways to misconfigure.
- Backplane needs Redis pub/sub: a managed Redis tier without pub/sub cannot run it.
Output Cache
Output Cache is middleware that stores the full HTTP response and serves it on the next matching request.
On a hit, your handler and your database never run. This is the biggest win per line of code in ASP .NET Core, and I wrote a dedicated deep dive on Output Cache.
During registration, you define named policies:
csharpbuilder.Services.AddOutputCache(options => { options.AddPolicy("shipment-by-number", policy => policy .Expire(TimeSpan.FromMinutes(5)) .SetVaryByRouteValue("shipmentNumber") .Tag("shipments")); }); var app = builder.Build(); app.UseOutputCache();
After that, the endpoint can use it by name:
csharpapp.MapGet("/api/shipments/{shipmentNumber}", async ( string shipmentNumber, GetShipmentByNumberHandler handler, CancellationToken cancellationToken) => { var shipment = await handler.HandleAsync(shipmentNumber, cancellationToken); return shipment is null ? Results.NotFound() : Results.Ok(shipment); }) .CacheOutput("shipment-by-number");
SetVaryByRouteValue makes each shipment number its own cache entry.
For list endpoints, SetVaryByQuery("carrier", "status") does the same for query parameters.
Use Tag("shipments") to quickly evict items from a cache.
You can inject IOutputCacheStore and evict by tag:
csharpinternal sealed class UpdateShipmentStatusHandler( ShipmentsDbContext dbContext, IOutputCacheStore outputCache) { public async Task HandleAsync( string shipmentNumber, ShipmentStatus status, CancellationToken cancellationToken) { var shipment = await dbContext.Shipments .FirstAsync(s => s.Number == shipmentNumber, cancellationToken); shipment.UpdateStatus(status); await dbContext.SaveChangesAsync(cancellationToken); await outputCache.EvictByTagAsync("shipments", cancellationToken); } }
By default, Output Cache stores responses in memory, which brings back the two-instances problem. For such cases, you will need Redis:
bashdotnet add package Microsoft.AspNetCore.OutputCaching.StackExchangeRedis
csharpbuilder.Services.AddStackExchangeRedisOutputCache(options => { options.Configuration = builder.Configuration.GetConnectionString("Redis"); });
Now every instance serves from the same cached responses, and EvictByTagAsync on one instance evicts for all of them.
Important note: ASP .NET Core also has the older Response Caching middleware (UseResponseCaching), which relies on Cache-Control headers sent by the client.
It is outdated for APIs. Clients can bypass it, you cannot evict by tag, and it does not support Redis.
Use Output Cache instead.
Output Cache has the following advantages:
- Nothing runs on a hit: no handler, no serialization, no database.
- Declarative: one attribute-like call per endpoint, policies in one place.
- Tag-based eviction that works across instances with the Redis store.
Here are the drawbacks of Output Cache:
- Whole-response granularity: you cache the entire payload or nothing. One changed field invalidates everything.
- Only GET and HEAD, and only 200 responses, by default.
- Authenticated requests are not cached by default. Requests with an
Authorizationheader skip the cache entirely.
Output Cache Pitfalls
Caching per-user data without varying by user. If you override the default and cache authenticated responses, you must vary by the user, or user A receives user B's shipments:
csharpoptions.AddPolicy("my-shipments", policy => policy .Expire(TimeSpan.FromMinutes(1)) .SetVaryByHeader("Authorization") .Tag("shipments"));
Varying by the Authorization header works, but every token is its own entry, so the hit rate drops as the user base grows.
Eviction lives in the write path. Every handler that changes shipments must remember to evict the tag; otherwise, stale responses persist until they expire. Put eviction in one place, such as a domain event handler, rather than in each write handler.
Which Cache Should You Choose
The right cache depends on three questions: how many instances you run, how stale the data may be, and what you are caching.
Here is the decision path I use:
I didn't include IDistributedCache here, as it's just the L2 behind HybridCache and FusionCache, and that is how you should reach it.
When to Use IMemoryCache
Best for:
- Objects that cannot be serialized
- Very large immutable lookup data read thousands of times per request
- Small tools and single-instance apps
Choose IMemoryCache when:
- Deserializing the object on every read would cost more than the database query you are avoiding
- You can bound the key count and set a
SizeLimit - Each instance having its own copy is fine
When to Use IDistributedCache
Best for:
- The L2 behind HybridCache or FusionCache
- Sharing small values between different applications through one Redis
Choose IDistributedCache directly when:
- You need raw control over the bytes stored in Redis
- You are on .NET 8 or earlier and cannot add HybridCache
When to Use HybridCache
Best for:
- Any new .NET 9+ application as the default
- Single instance, or multiple instances with tolerable staleness
- Hot keys that need stampede protection
Choose HybridCache when:
- You want a built-in library with no third-party dependency
- You plan to start without Redis and add it later
- Seconds of L1 staleness after a write do not matter for your data
When to Use FusionCache
Best for:
- Multiple instances that need almost immediate consistency after writes
- APIs that must stay up when the database is slow or down
- Teams that outgrew HybridCache and want to keep their handlers
Choose FusionCache when:
- You hit the L1 sync pitfall and a short
LocalCacheExpirationis not enough - You need fail-safe, soft timeouts, or eager refresh
- Your Redis supports pub/sub
When to Use Output Cache
Best for:
- Public read endpoints with identical responses for all users
- Responses that change rarely and can be evicted by tag
- Adding on top of an object cache for a second layer
Choose Output Cache when:
- Skipping the handler entirely is a win
- The whole response changes as a whole
- Per-user variation is small or unnecessary
My Recommendation
In a new project, start with FusionCache if you plan to use Redis and have multiple app instances. And you don't mind having a third-party dependency (I am sure this project is great).
If you want to improve an existing project, start with HybridCache in L1-only mode. It is built in, it prevents stampedes, and you can add Redis with one line when you scale. And later, you can also use FusionCache as HybridCache (1 line of code).
From there:
- Single instance? HybridCache L1-only. Done.
- Multiple instances and stale-for-a-few-seconds is fine? HybridCache with Redis L2 and a short
LocalCacheExpiration. - Multiple instances and writes must be visible immediately? FusionCache with a Redis backplane. Use
AsHybridCache()to keep your handlers. - Public read-heavy endpoints? Add Output Cache with the Redis store on top of whichever object cache you picked.
- A huge immutable object you read constantly? Keep it in
IMemoryCache, on purpose, and document why.
Summary
Let's recap the key takeaways:
- IMemoryCache is the fastest. Nanosecond reads, no serialization, but per-process only, no tags, and no stampede protection.
- IDistributedCache with Redis is shared but slow per read. Every hit is a network hop and a deserialization. Use it as the L2 behind a hybrid cache.
- HybridCache is the default for .NET 9+. L1 + L2, stampede protection, tags, and a one-line Redis upgrade. Know its pitfalls: unsynced L1, null cached as a hit, option merging.
- FusionCache fixes what HybridCache lacks. A backplane keeps L1 in sync across instances, and fail-safe keeps your API answering when the database is not.
AsHybridCache()makes it a drop-in. - Output Cache skips your code entirely. Cache whole responses for public read endpoints, evict by tag from the write side, and use the Redis store on multiple instances. Response Caching middleware is outdated; use Output Cache.
- Layer them. Output Cache in front, a hybrid cache inside handlers, and
IMemoryCacheonly for objects that should never be serialized.
There is no single correct cache. Pick the one that matches your instance count, your tolerance for stale data, and the shape of what you cache, and be ready to add a second layer when the first one is not enough.
Hope you find this newsletter useful. See you next time.

