Skip to main content
SEMastery
ASP.NETbeginner

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.

12 min readUpdated February 24, 2026

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.

Without a cache, every request does the slow work. With a cache, repeat requests are answered instantly.

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.

StrategyWhere data livesBest forShared across servers?
IMemoryCacheRAM of one serverSingle-server apps, simple needsNo
IDistributedCacheA shared store like RedisMany servers behind a load balancerYes
HybridCacheRAM (L1) + shared store (L2)Most new apps; best of bothYes (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

L1 Memory
L2 Redis
Database

Steps

1

L1 Memory

Check fast in-process cache first

2

L2 Redis

On miss, check shared distributed cache

3

Database

On miss, do the real work and fill caches

The request checks the nearest, fastest layer first and only goes deeper on a miss.

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.

Many app servers share one distributed cache, so they all see the same data.

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.

Without protection, many simultaneous misses stampede the database. HybridCache lets only one through.

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 typeMeaningExample use
AbsoluteEntry dies a fixed time after it was created"Drop after 10 minutes no matter what"
SlidingTimer 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

Data changes often?
Use short expiration
Data changes on events?
Invalidate on change

Steps

1

Changes often?

Use short absolute expiration

2

Rarely changes?

Use longer expiration

3

Changes on save/edit?

Invalidate by key or tag

4

Mixed?

Combine both for safety

Pick expiration for time-based freshness and invalidation for change-based freshness.

Choosing the right strategy

You do not need to memorize rules. Just ask a few simple questions about your app.

A simple decision path for picking a caching strategy.

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

Related Posts