How to Implement Caching Strategies in .NET: A Beginner-Friendly Guide
Learn caching strategies in .NET with simple examples: in-memory cache, distributed Redis cache, and HybridCache, plus eviction, stampede protection, and tags.
A water bottle on your study table
Think about a hot afternoon while you are studying. You feel thirsty. The kitchen, with the water filter, is at the far end of the house. If you walked all the way to the kitchen every single time you wanted one sip, you would waste a lot of time and energy.
So what do clever students do? They fill a water bottle once and keep it right on the study table. Now every sip is instant. You only walk to the kitchen again when the bottle is empty.
That water bottle is exactly what a cache is. The kitchen is your slow database or external API. The bottle is a fast little store of data sitting close to your app. Caching means: do the slow work once, keep the answer nearby, and reuse it.
In this guide you will learn the main caching strategies in .NET, when to use each one, and how to write them with clean, modern code. We will use .NET 10, which is the current LTS release.
Why caching matters
Every time your app talks to a database, calls another service, or runs a heavy calculation, it takes time. That time is small for one user. But when thousands of users ask for the same thing, the cost adds up fast. The database gets tired. Pages load slowly. People leave.
Caching fixes this by saving the answer to expensive work. The next request gets the saved copy in microseconds instead of redoing the work.
The two words you will hear a lot are:
- Cache hit — the data was already in the cache. Fast and cheap.
- Cache miss — the data was not there, so we did the slow work and then saved it.
A good cache has many hits and few misses.
The three main caching strategies in .NET
.NET gives you three main tools. Each one fits a different situation. Let us meet them.
| Strategy | Where data lives | Best for | Shared across servers? |
|---|---|---|---|
| IMemoryCache | RAM of one server | Single-server apps, simple needs | No |
| IDistributedCache | A shared store like Redis | Many servers behind a load balancer | Yes |
| HybridCache | RAM (L1) + shared store (L2) | Most new apps; best of both | Yes (L2 layer) |
Let us look at how these layers fit together. In-memory is the closest and fastest. A distributed store like Redis is a little further away but shared. The real database is the slowest source of truth.
How a request travels through cache layers
Steps
L1 Memory
Check fast in-process cache first
L2 Redis
On miss, check shared distributed cache
Database
On miss, do the real work and fill caches
Strategy 1: In-memory caching with IMemoryCache
This is the simplest cache. It stores data in the memory (RAM) of the one server running your app. It is very fast because the data never leaves the process.
First, register it in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Add the in-memory cache service
builder.Services.AddMemoryCache();
var app = builder.Build();Now use it in a service or endpoint. The key method is GetOrCreateAsync. It checks the cache for a key. If the value is there, it returns it. If not, it runs your function, saves the result, and returns it.
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly IProductRepository _repo;
public ProductService(IMemoryCache cache, IProductRepository repo)
{
_cache = cache;
_repo = repo;
}
public async Task<Product?> GetProductAsync(int id)
{
return await _cache.GetOrCreateAsync($"product_{id}", async entry =>
{
// This runs only on a cache miss
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return await _repo.GetByIdAsync(id);
});
}
}The first call for a product is a miss, so it hits the database. Every call after that, for 10 minutes, is a fast hit straight from memory.
The big limit: this cache lives inside one server. If you run two copies of your app behind a load balancer, each copy has its own separate bottle. One server may have fresh data while another has old data. For a single server, this is perfect. For many servers, you need the next strategy.
Strategy 2: Distributed caching with IDistributedCache
A distributed cache stores data in a separate, shared store that all your servers can reach. The most popular store is Redis. Now every server drinks from the same bottle. The cache also survives an app restart, because the data lives outside the app.
To use Redis, add the package and register it:
// dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "MyApp_";
});IDistributedCache works with bytes and strings, not full objects. So you usually convert your object to JSON before saving and back again when reading:
public async Task<Product?> GetProductAsync(int id)
{
var key = $"product_{id}";
var cached = await _cache.GetStringAsync(key);
if (cached is not null)
{
return JsonSerializer.Deserialize<Product>(cached);
}
var product = await _repo.GetByIdAsync(id);
if (product is not null)
{
var json = JsonSerializer.Serialize(product);
await _cache.SetStringAsync(key, json, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
}
return product;
}This is shared and reliable, but notice two costs. First, there is network latency — reaching Redis is slower than reading local RAM. Second, you write more boilerplate code for serializing and checking. The next strategy removes both pains.
Strategy 3: HybridCache (the modern default)
HybridCache combines the best of both worlds. It keeps a fast L1 in-memory layer on each server, and an optional L2 distributed layer like Redis shared between servers. You write one simple call, and it manages both layers, the serialization, and a few smart extra features for you.
HybridCache became generally available in .NET 9 and is stable in .NET 10. For most new projects, this is where you should start.
Register it like this:
// dotnet add package Microsoft.Extensions.Caching.Hybrid
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // total lifetime (L2)
LocalCacheExpiration = TimeSpan.FromMinutes(2) // L1 memory lifetime
};
});
// Optional: add Redis as the L2 layer
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});Now the code is wonderfully short. No manual JSON. No miss-check ladder:
public class ProductService
{
private readonly HybridCache _cache;
private readonly IProductRepository _repo;
public ProductService(HybridCache cache, IProductRepository repo)
{
_cache = cache;
_repo = repo;
}
public async Task<Product?> GetProductAsync(int id, CancellationToken ct = default)
{
return await _cache.GetOrCreateAsync(
$"product_{id}",
async token => await _repo.GetByIdAsync(id, token),
tags: ["products"],
cancellationToken: ct);
}
}That one call checks L1, then L2, then runs your factory on a full miss, and fills both layers. It also gives you two features that are hard to build by hand: stampede protection and tag-based invalidation. We will cover both next.
Stampede protection: avoiding the rush
Here is a problem that bites busy apps. Imagine a very popular product. Its cache entry expires at exactly 2:00 PM. At that same second, 500 users ask for it. All 500 get a cache miss. So all 500 run the same database query at once. The database is suddenly hammered by a stampede.
HybridCache solves this automatically. When many requests miss the same key at once, it lets only one request run the factory (the database query). The others wait for that single result and share it. Your database breathes easy.
If you only used plain IMemoryCache or IDistributedCache, you would have to write this locking logic yourself. With HybridCache, you get it for free.
Keeping cache data fresh: eviction and invalidation
A cache is only useful if it does not lie. Old, wrong data is called stale data. There are two ways data leaves the cache.
1. Expiration (automatic). You set a time, and the entry is dropped when that time passes. There are two common kinds:
| Expiration type | Meaning | Example use |
|---|---|---|
| Absolute | Entry dies a fixed time after it was created | "Drop after 10 minutes no matter what" |
| Sliding | Timer resets each time the entry is used | "Keep while popular, drop when idle" |
2. Invalidation (manual). You remove an entry the moment the real data changes. For example, when an admin edits a product, you clear that product's cache so users see the new price right away.
With HybridCache, invalidation by tag is very handy. Because we tagged our products with "products" earlier, we can clear all of them in one line:
public async Task UpdateProductAsync(Product product, CancellationToken ct = default)
{
await _repo.UpdateAsync(product, ct);
// Clear this one entry
await _cache.RemoveAsync($"product_{product.Id}", ct);
// Or clear every entry tagged "products" at once
await _cache.RemoveByTagAsync("products", ct);
}One honest note: when you invalidate by tag, the entry is cleared on the current server and in the shared L2 store, but the L1 memory copies on other servers are not instantly wiped. They simply expire on their own short timers. That is why keeping a short LocalCacheExpiration is a good idea.
Deciding how to keep cache fresh
Steps
Changes often?
Use short absolute expiration
Rarely changes?
Use longer expiration
Changes on save/edit?
Invalidate by key or tag
Mixed?
Combine both for safety
Choosing the right strategy
You do not need to memorize rules. Just ask a few simple questions about your app.
Here is the short advice:
- One server, simple needs? Use
IMemoryCache. - Many servers, and you want speed plus sharing? Use
HybridCache. This is the modern default. - Many servers, and you only need a plain shared store? Use
IDistributedCachewith Redis.
When in doubt, reach for HybridCache. It scales down to a single in-memory layer if you do not add Redis, and scales up when you do.
What to cache and what not to cache
Caching is powerful, but it is not free. Use it with care.
Good things to cache:
- Data that is read far more often than it is written, like a product catalog.
- Results of slow queries or external API calls.
- Data that can be a little out of date without harm, like a list of blog posts.
Be careful caching:
- Data that must always be exact this second, like a bank balance.
- Data that is unique to one user and rarely reused, since it fills memory without many hits.
- Very large objects that could eat all your memory.
A simple habit: always set an expiration. A cache without any expiry can grow forever and hold stale data. Give every entry a sensible lifetime.
A note on memory limits
In-memory caching uses your server's RAM, which is limited. If you cache too much, you can run out of memory. IMemoryCache lets you set a size limit and give each entry a size, so the cache can evict the least useful entries when it gets full. HybridCache also manages its L1 layer with sensible defaults, but you should still keep entries reasonably small and expirations short. Treat memory like a small bottle, not an endless tank.
Quick recap
- A cache stores the answer to slow work so repeat requests are instant — like a water bottle on your table instead of walking to the kitchen.
- A cache hit uses saved data; a cache miss does the slow work and then saves it.
- IMemoryCache is fast and simple but lives on one server only.
- IDistributedCache (often Redis) is shared across servers and survives restarts, but adds network latency and boilerplate.
- HybridCache combines an L1 memory layer and an L2 distributed layer with one clean API. It is the modern default in .NET 9 and .NET 10.
- Stampede protection in HybridCache lets only one request rebuild a missing entry, sparing your database.
- Keep data fresh with expiration (time-based) and invalidation (change-based, including tags).
- Always set an expiration, cache read-heavy data, and avoid caching data that must be perfectly exact every second.
References and further reading
- Overview of caching in ASP.NET Core — Microsoft Learn
- HybridCache library in ASP.NET Core — Microsoft Learn
- Distributed caching in ASP.NET Core — Microsoft Learn
- Caching in .NET — Microsoft Learn
- Hello HybridCache! — .NET Blog
- HybridCache in ASP.NET Core — Milan Jovanović
Related Posts
Caching in ASP.NET Core: Make Your App Fast (The Easy Way)
Learn caching in ASP.NET Core with simple examples. Understand in-memory cache, distributed Redis cache, HybridCache, and output cache, with diagrams, code, and clear advice on which to use and when.
ASP.NET Core Output Cache: Speed Up Your API with In-Memory and Redis
Learn ASP.NET Core output caching the easy way: cache whole API responses in memory or in Redis, set policies, vary by query, and clear with tags — with diagrams and code.
HybridCache in ASP.NET Core: The New Caching Library Explained
Learn HybridCache in ASP.NET Core the simple way: two-level caching, stampede protection, tag invalidation, GetOrCreateAsync, and Redis setup with clear diagrams.
How to Increase the Performance of Web APIs in .NET
A friendly, step-by-step guide to making your ASP.NET Core Web APIs fast: async, caching, query tuning, compression, and pooling in .NET 10.
Make Your ASP.NET Core Web API 18x Faster with HybridCache
Learn how HybridCache in .NET 9 speeds up your ASP.NET Core Web API up to 18x, with L1/L2 caching, stampede protection, and tags, explained simply.
Solving Distributed Cache Invalidation with Redis and HybridCache
Learn how Redis and HybridCache solve distributed cache invalidation in ASP.NET Core with tags, backplanes, and a simple kitchen-counter analogy.