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.
👉 Read original article on my newsletter: https://antondevtips.com/blog/all-types-of-caching-in-dotnet-and-when-to-use-each-part2
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:
dotnet 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:
var 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:
internal 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:
await 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:
builder.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:
builder.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:
app.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:
internal 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:
dotnet add package Microsoft.AspNetCore.OutputCaching.StackExchangeRedis builder.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:
options.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.
👉 Read original article on my newsletter: https://antondevtips.com/blog/all-types-of-caching-in-dotnet-and-when-to-use-each-part2