newsletter

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

7 min read

Your API is slow and Claude recommends you to add caching.

It adds IMemoryCache to the shipment tracking API, and the p95 latency drops from 120 ms to 5 ms.

Everyone is happy.

Then they scale to two instances.

A customer updates the delivery address, refreshes the page, and sees the old one. Refreshes again and sees the new one. One more refresh, and the old address is back.

Nothing is broken. Each instance has its own memory, and only one of them knows that the address has changed.

Caching is the cheapest performance win in .NET, and one of the easiest things to get wrong.

The first and most important rule is: do not add caching to hide your slow database queries. It's like fixing symptoms.

First, fix your database queries (the root cause), and only then add caching to increase your throughput and make your API even faster.

In .NET, you have 6 ways to cache: IMemoryCache, IDistributedCache, Redis, HybridCache, FusionCache, and Output Cache.

In this post, we will explore each caching option with pros, cons, pitfalls, and when to use it:

  • Where Each Cache Sits in a .NET App
  • In-Memory Cache (IMemoryCache)
  • Distributed Cache (IDistributedCache) with Redis
  • HybridCache
  • FusionCache (Part 2)
  • Output Cache (Part 2)
  • Which Cache Should You Choose (Part 2)

Let's dive in.

It's a very long newsletter (no one will read such a long article), so I will release PART 2 next week.

Copied

Where Each Cache Sits in a .NET App

Every cache in .NET sits somewhere on the path between an HTTP request and your database.

Some sit in front of your code and cache whole HTTP responses. Others sit inside your handlers and cache .NET objects, either in the memory of one process or in an external store shared by every instance.

Here is the full picture:

Loading diagram…

You need to know these three terms:

  • L1 cache is the memory of the current process. It is the fastest cache you can have because there is no network request and no deserialization.
  • L2 cache is an external store like Redis, shared by all instances of your app. Slower than L1 (but much faster than a database) and every instance sees the same data.
  • Cache stampede happens when many requests miss the cache at the same moment and all of them hit the database for the same key.

In this article, we will explore the shipments domain and we will cache shipments.

Here is the Minimal API endpoint that calls a handler, and the handler queries EF Core directly:

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); }); internal sealed class GetShipmentByNumberHandler(ShipmentsDbContext dbContext) { public async Task<ShipmentResponse?> HandleAsync( string shipmentNumber, CancellationToken cancellationToken) { return await dbContext.Shipments .Where(s => s.Number == shipmentNumber) .Select(s => s.MapToResponse()) .FirstOrDefaultAsync(cancellationToken); } }

This query is already fast. We have indexed on the Shipment.Number field and we also select only the necessary fields.

Let's add a cache to this query.

By the way, did you know that Redis is one of the best tools for your AI-driven apps?

When multiple users write similar queries but with different wording, you waste a lot of tokens (and money). Redis solves this problem. See below.

Copied

In-Memory Cache (IMemoryCache)

IMemoryCache is a key-value store that lives in the memory of your application process.

You store any .NET object in it, and you get the same object reference back. When you get items from an IMemoryCache, they don't require serialization and you don't need any network requests.

To use IMemoryCache in your application, add it into the DI container:

csharp
builder.Services.AddMemoryCache();

In the handler, we will use the cache-aside strategy on top of the database query:

csharp
internal sealed class GetShipmentByNumberHandler( ShipmentsDbContext dbContext, IMemoryCache cache) { public async Task<ShipmentResponse?> HandleAsync( string shipmentNumber, CancellationToken cancellationToken) { return await cache.GetOrCreateAsync($"shipment:{shipmentNumber}", async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); entry.SetSize(1); return await dbContext.Shipments .Where(s => s.Number == shipmentNumber) .Select(s => s.MapToResponse()) .FirstOrDefaultAsync(cancellationToken); }); } }

GetOrCreateAsync first checks the cache, calls the database if the item is not found in the cache, and stores the result returned by the factory method. The entry expires 10 minutes after it was written.

This is the fastest cache you can have in .NET. A hit is a dictionary lookup that returns a reference. There is nothing to deserialize.

IMemoryCache has the following advantages:

  • Zero infrastructure: no Redis, no connection strings, nothing to deploy.
  • Fastest possible reads: a hit costs nanoseconds and allocates nothing.
  • Any object: you can cache types that are not serializable, including compiled regexes or large immutable lookup tables.

Here are the drawbacks of IMemoryCache:

  • Per-process only: two instances of your API have two separate caches that never talk to each other.
  • Lost on restart: every deployment starts with a cold cache.
  • Shares memory with your app: a cache that grows without a limit can cause memory issues.
Copied

IMemoryCache Pitfalls

There is no size limit by default. SetSize(1) in the code above does nothing until you set a SizeLimit on the cache itself:

csharp
builder.Services.AddMemoryCache(options => { options.SizeLimit = 10_000; });

The size unit is whatever you decide. Here, each shipment counts as 1, so the cache can hold at most 10,000 shipments. Once you set SizeLimit, every entry must call SetSize, or the cache throws when you add it.

The cache hands out the same reference every time. If a caller mutates the object it got from the cache, every later caller sees the mutation. This is a classic source of bugs that only appear under load:

csharp
var response = await cache.GetOrCreateAsync(key, ...); response.Items.Clear(); // every next request now gets a shipment with no items.

Cache immutable records, or return a copy.

There is no way to remove entries by prefix or tag. IMemoryCache has Remove(key) and nothing else. If you need to drop all cached shipments for an order, you need to store all the keys to be able to delete them:

GetOrCreateAsync has no stampede protection. If 100 requests miss the same key at the same time, 100 factories run and 100 database queries execute. IMemoryCache does not lock per key. You have to add a SemaphoreSlim yourself (bad!) or use a cache that does it for you.

When you scale to several instances, you need a store they all share.

Copied

Distributed Cache (IDistributedCache) with Redis

IDistributedCache is an abstraction over an external cache store. The most common implementation is Redis, which we will use.

bash
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
csharp
builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = builder.Configuration.GetConnectionString("Redis"); options.InstanceName = "shipments:"; });

InstanceName is a prefix added to every key, so several apps can share one Redis without key collisions.

The interface works with byte[] and strings only. You need to serialize and deserialize yourself:

csharp
internal sealed class GetShipmentByNumberHandler( ShipmentsDbContext dbContext, IDistributedCache cache) { public async Task<ShipmentResponse?> HandleAsync( string shipmentNumber, CancellationToken cancellationToken) { var key = $"shipment:{shipmentNumber}"; var cached = await cache.GetStringAsync(key, cancellationToken); if (cached is not null) { return JsonSerializer.Deserialize<ShipmentResponse>(cached); } var shipment = await dbContext.Shipments .Where(s => s.Number == shipmentNumber) .Select(s => s.MapToResponse()) .FirstOrDefaultAsync(cancellationToken); if (shipment is not null) { await cache.SetStringAsync(key, JsonSerializer.Serialize(shipment), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }, cancellationToken); } return shipment; } }

Every instance reads and writes the same Redis keys, so when instance A caches a shipment, instance B reads it from Redis. When instance A removes the key, instance B will see this on the next read. So all the app instances get the same data.

IDistributedCache has the following advantages:

  • Shared by all instances: one cache, one source of truth.
  • Survives restarts: a new deployment starts warm.
  • Memory lives outside your app: Redis can hold gigabytes without touching your process (and you can move it to another server if needed, or even use Redis Cloud).

Here are the drawbacks of IDistributedCache:

  • Every read is a network round trip: a hit costs a millisecond or more instead of nanoseconds.
  • Every read deserializes: you pay for JSON parsing on each hit, plus the allocations that come with it.
Copied

IDistributedCache Pitfalls

Redis being down becomes your problem. GetStringAsync throws when Redis is unreachable, and that exception flows straight into your handler. A cache should never take your API down, so wrap the read:

csharp
string? cached = null; try { cached = await cache.GetStringAsync(key, cancellationToken); } catch (RedisConnectionException ex) { logger.LogWarning(ex, "Redis unavailable, falling back to database"); }

Also, in production you can have multiple Redis instances running and on different nodes. For more, read this article.

There's still no stampede protection. The check-then-set code above lets every concurrent miss query the database, exactly like IMemoryCache. On a hot key, a Redis restart can turn into a database spike.

Serialization drift after deployments. When you rename a property on ShipmentResponse and deploy, Redis still holds the old JSON. Deserialization silently gives you a default value for the renamed property.

To solve this, you can put a version in the key (shipment:v2:{number}) and update it when the shape changes.

Copied

HybridCache

HybridCache was released with .NET 9 as a replacement for both IMemoryCache and IDistributedCache.

It combines them: an L1 in memory for speed, and an optional L2 in Redis for sharing. It also solves the stampede problem because concurrent requests for the same key share a single factory call.

bash
dotnet add package Microsoft.Extensions.Caching.Hybrid
csharp
builder.Services.AddHybridCache(options => { options.MaximumPayloadBytes = 1024 * 1024; options.DefaultEntryOptions = new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10), LocalCacheExpiration = TimeSpan.FromMinutes(10) }; });

Without any other registration, HybridCache runs L1-only (memory). The moment you register an IDistributedCache, it becomes the L2 automatically:

csharp
builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = builder.Configuration.GetConnectionString("Redis"); });

You can start using HybridCache with a memory cache and later add Redis without changing the code that calls the cache.

This is how you use HybridCache:

csharp
internal sealed class GetShipmentByNumberHandler( ShipmentsDbContext dbContext, HybridCache cache) { public async Task<ShipmentResponse?> HandleAsync( string shipmentNumber, CancellationToken cancellationToken) { return await cache.GetOrCreateAsync( $"shipment:{shipmentNumber}", (dbContext, shipmentNumber), static async (state, ct) => await state.dbContext.Shipments .Where(s => s.Number == state.shipmentNumber) .Select(s => s.MapToResponse()) .FirstOrDefaultAsync(ct), tags: ["shipments"], cancellationToken: cancellationToken); } }

The (state, ct) overload with a static lambda avoids capturing this and allocating a closure on every call.

The tags parameter is what IMemoryCache lacks. A tag groups keys, so you can drop all shipments at once.

Use RemoveAsync to remove a single item and RemoveByTagAsync("shipments") would drop all of them, which is what you want after a bulk import.

On a read, HybridCache checks L1 first, then L2, then runs the factory (reads your database). If it reads from a factory method from the database, it writes to both L2 and L1. Concurrent requests for the same key wait for the one factory that is already running.

HybridCache has the following advantages:

  • L1 + L2 in one API: memory speed on hits, Redis for sharing across instances.
  • Stampede protection built in: one factory call per key, no matter how many requests miss at once.
  • Tags: invalidate groups of keys in one call.
  • Zero config upgrade path: start L1-only, add Redis with one line in DI.
  • Part of .NET: no third-party dependency, Microsoft maintains it.

Here are the drawbacks of HybridCache:

  • L1 caches are not synchronized between instances. More on this below, because it is the pitfall that matters most.
  • Everything is serialized, even in L1-only mode.
  • No sliding expiration, no fail-safe, no background refresh.
Copied

HybridCache Pitfalls You Need to Know

These are the ones that I faced in production before I learned about them.

1. L1 is not synced across instances.

Register Redis as L2, run two instances, and you get this:

Loading diagram…

RemoveAsync on instance A clears its own L1 and the shared L2. Instance B still has the old value in its L1 and serves it until LocalCacheExpiration passes.

HybridCache has no backplane: there's no message that tells other instances "drop this key".

The only thing you have is a short LocalCacheExpiration:

csharp
options.DefaultEntryOptions = new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(5), LocalCacheExpiration = TimeSpan.FromSeconds(5) };

Now the L2 entry lives for 30 minutes, but each instance re-checks Redis every 5 minutes. That caps staleness at 5 minutes per instance. Bad!

2. A factory that returns null is cached as a hit.

HybridCache does not treat null or an empty list as a miss. When the shipment is not found, null is stored for the full TTL period, and the shipment remains invisible even after it is created.

The fix is to remove the entry when the factory comes back empty:

csharp
var shipment = await cache.GetOrCreateAsync(key, state, factory, cancellationToken: cancellationToken); if (shipment is null) { await cache.RemoveAsync(key, cancellationToken); } return shipment;

Removing after the fact keeps stampede protection for the requests that were already waiting on the same key.

3. Per-call options merge with the defaults, one property at a time.

This one is subtle. Say the default LocalCacheExpiration is 5 minutes and you write:

csharp
await cache.SetAsync(key, value, new HybridCacheEntryOptions { Expiration = TimeSpan.FromHours(1) });

You asked for one hour. In L1-only mode, you get five minutes.

Expiration is set, LocalCacheExpiration is null, and a null property inherits the default. With no L2, the L1 expiration is the only one, so your entry dies after 5 minutes.

Always set both, and do it through a helper so nobody forgets:

csharp
public static class HybridCacheEntry { public static HybridCacheEntryOptions Ttl(TimeSpan ttl) => new() { Expiration = ttl, LocalCacheExpiration = ttl }; }

4. No sliding expiration.

Every entry has an absolute TTL from the moment it is written. If you relied on IMemoryCache sliding expiration for session-like data, this is a behavior change.

This is it for PART 1 of this newsletter.

In Part 2, we will explore:

  • FusionCache
  • Output Cache
  • Which Cache Should You Choose

Stay tuned.

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