newsletter

All Types of Caching in .NET: When to Use Each (Deep Dive, Part 2)

6 min read

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.

Copied

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:

bash
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:

csharp
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 RedisCache type from IDistributedCache.
  • Backplane as the channel that tells other instances about changes. It uses Redis pub/sub.

The handler uses IFusionCache:

csharp
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:

csharp
await cache.RemoveAsync($"shipment:{shipmentNumber}", token: cancellationToken);

The difference is what happens after that call on the other app instances:

Loading diagram…

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.

Copied

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:

csharp
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 HybridCacheEntryOptions per call. Fail-safe, timeouts, and eager refresh are configured once in the default entry options.

Copied

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

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:

csharp
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:

csharp
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:

csharp
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:

bash
dotnet add package Microsoft.AspNetCore.OutputCaching.StackExchangeRedis
csharp
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 Authorization header skip the cache entirely.
Copied

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:

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

Copied

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:

Loading diagram…

I didn't include IDistributedCache here, as it's just the L2 behind HybridCache and FusionCache, and that is how you should reach it.

Copied

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
Copied

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
Copied

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
Copied

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 LocalCacheExpiration is not enough
  • You need fail-safe, soft timeouts, or eager refresh
  • Your Redis supports pub/sub
Copied

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
Copied

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

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 IMemoryCache only 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.

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

Covers everything: C#, ASP.NET Core, EF Core, system design — answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
Join 500+ developers

Not sure where you stand? Take the free .NET interview test:

  • Find out your real level — Junior to Senior+
  • A realistic mock .NET interview — across 13 areas of C#, .NET, ASP.NET Core and System Design

No credit card required. When you finish, you get a personalized report: your level, your strongest and weakest areas, and where to focus next — the perfect way to benchmark yourself before diving into the Playbook.

Start the free test

Master .NET and Software Architecture,
so AI can't replace you

Real-world architecture judgment, production trade-offs, and .NET in depth — the things that keep you relevant in the job market.

Plus a free PDF: 200 senior .NET interview questions on C#, ASP.NET Core, EF Core & System Design.

Join 28,000+ developers already reading
No spam. Unsubscribe any time.

Enjoyed this article? Share it with your network