Articles › ASP.NET Core

Caching in ASP.NET Core: IMemoryCache, Redis, HybridCache and Output Caching

Speed up ASP.NET Core apps with the right cache: in-memory caching, distributed Redis cache, HybridCache, output caching, HTTP caching and safe invalidation strategies.

Caching is the most effective performance optimization you can make: a response served from memory takes microseconds instead of a database round trip. But a cache also introduces the two hardest problems in computing — invalidation and consistency. This guide covers every caching layer ASP.NET Core offers and how to use each safely.

What to cache

Good candidates:

  • Data read often and changed rarely: product catalogs, published articles, settings, menus.
  • Expensive computations: reports, aggregates, rendered markdown.
  • Results of slow external APIs: exchange rates, geolocation lookups.

Bad candidates: per-user sensitive data in shared caches (unless keyed by user), rapidly changing values, and anything where stale data causes real harm (account balances).

1. In-memory cache with IMemoryCache

builder.Services.AddMemoryCache();
public class ArticleService(AppDbContext db, IMemoryCache cache)
{
    public async Task<IReadOnlyList<ArticleSummary>> GetLatestAsync(CancellationToken ct)
    {
        return await cache.GetOrCreateAsync("articles:latest", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            entry.SlidingExpiration = TimeSpan.FromMinutes(2);
            entry.Size = 1;

            return await db.Articles.AsNoTracking()
                .Where(a => a.IsPublished)
                .OrderByDescending(a => a.PublishedAt)
                .Take(6)
                .Select(a => new ArticleSummary(a.Id, a.Slug, a.Title, a.PublishedAt))
                .ToListAsync(ct);
        }) ?? [];
    }
}
  • Absolute expiration — removed after a fixed time, no matter what.
  • Sliding expiration — removed if not accessed for a period; always combine with an absolute limit so hot entries eventually refresh.
  • Size limits — set SizeLimit on the cache options to prevent unbounded memory growth.

Pros: extremely fast, zero setup. Cons: each server has its own cache. With several instances behind a load balancer, they can serve different data, and the cache is lost on every restart.

2. Distributed cache with Redis

When you run multiple instances, use a shared cache:

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

IDistributedCache works with bytes/strings, so you serialize yourself:

public class ExchangeRateService(IDistributedCache cache, HttpClient http)
{
    public async Task<Rates> GetAsync(CancellationToken ct)
    {
        var cached = await cache.GetStringAsync("rates", ct);
        if (cached is not null) return JsonSerializer.Deserialize<Rates>(cached)!;

        var rates = await http.GetFromJsonAsync<Rates>("/latest", ct);
        await cache.SetStringAsync("rates", JsonSerializer.Serialize(rates),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }, ct);
        return rates!;
    }
}

Redis survives app restarts, is shared by all instances and supports advanced data structures — but every access is a network call (typically under a millisecond on a local network).

3. HybridCache (.NET 9+): the best of both

HybridCache combines a fast in-memory L1 with an optional distributed L2, handles serialization for you, and — importantly — prevents cache stampedes:

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(30),
        LocalCacheExpiration = TimeSpan.FromMinutes(5)
    };
});
public class ArticleReader(HybridCache cache, AppDbContext db)
{
    public ValueTask<ArticleDto?> GetAsync(string slug, CancellationToken ct) =>
        cache.GetOrCreateAsync(
            $"article:{slug}",
            async token => await db.Articles.AsNoTracking()
                .Where(a => a.Slug == slug)
                .Select(a => new ArticleDto(a.Title, a.Content))
                .FirstOrDefaultAsync(token),
            tags: ["articles"],
            cancellationToken: ct);

    public ValueTask InvalidateAllAsync(CancellationToken ct) =>
        cache.RemoveByTagAsync("articles", ct);
}

A stampede happens when a popular entry expires and hundreds of concurrent requests all hit the database at once to rebuild it. HybridCache lets only one caller execute the factory while the others wait for its result.

4. Output caching: cache whole responses

For public GET endpoints, cache the HTTP response itself:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(b => b.Expire(TimeSpan.FromSeconds(30)));
    options.AddPolicy("Articles", b => b
        .Expire(TimeSpan.FromMinutes(10))
        .SetVaryByQuery("page", "tag")
        .Tag("articles"));
});

app.UseOutputCache();

app.MapGet("/api/articles", GetArticles).CacheOutput("Articles");

// After publishing an article:
await outputCacheStore.EvictByTagAsync("articles", ct);

Output caching skips your endpoint entirely on a hit — the fastest option of all. By default it doesn't cache authenticated requests or responses that set cookies, which protects you from leaking private data.

5. HTTP caching: let browsers and CDNs help

app.MapGet("/api/settings", () => Results.Ok(settings))
   .WithMetadata(new ResponseCacheAttribute { Duration = 300, Location = ResponseCacheLocation.Any });
  • Cache-Control: public, max-age=300 lets browsers and CDNs reuse the response.
  • Static assets with hashed file names (app.4f9c2.js) can use max-age=31536000, immutable.
  • ETags allow 304 Not Modified responses when content hasn't changed.

Cache invalidation strategies

  1. Time-based expiration — simplest; accept data being up to N minutes old.
  2. Explicit invalidation — remove keys or tags when data changes (after SaveChanges).
  3. Versioned keys — include a version in the key (articles:v42) and bump it on change.
  4. Event-driven — publish a message so all instances evict their local entries.

A practical combination: explicit invalidation on writes plus a safety-net absolute expiration.

Key naming and other best practices

  • Use structured keys: article:{slug}, user:{id}:permissions, articles:list:page:{n}.
  • Include every parameter that changes the result (language, page, filters, tenant).
  • Cache DTOs, not EF Core entities (tracked entities and navigation cycles don't belong in a cache).
  • Never cache per-user data under a shared key.
  • Measure hit ratio — a cache with a 5% hit rate is just overhead.

Choosing the right cache

Scenario Choice
Single server, small data IMemoryCache
Multiple servers, shared data Redis (IDistributedCache)
Modern .NET, want L1+L2 and stampede protection HybridCache
Public GET endpoints Output caching
Static files, public resources HTTP caching / CDN

Summary

Cache what's read often and changed rarely, choose the layer that matches your deployment (memory, Redis, HybridCache, output cache, HTTP), use clear keys that include every input, and plan invalidation from day one. A well-designed cache can cut response times by orders of magnitude while keeping data correct.

FAQ

Should I cache EF Core entities?

No — cache projected DTOs. Entities can carry tracking state and large navigation graphs.

How long should cache entries live?

As long as stale data is acceptable for that use case. Start conservative (minutes) and tune with real metrics.

Is Redis required for caching?

No. It's needed when several instances must share a cache or when the cache should survive restarts.