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

ASP.NET Core has several kinds of cache, and each one solves a different problem. IMemoryCache keeps objects in the memory of the process itself, and IDistributedCache with Redis shares data across servers. HybridCache combines the two in a two-level cache (L1 in the process, L2 in Redis) and keeps concurrent requests from stampeding the data source. Output caching stores the whole HTTP response on the server. In this guide I explain what each layer is for, how to choose keys and TTLs, what tag invalidation does and does not do, and what you should not cache.

The cache layers and what each one does

Before you pick a tool, answer two questions. Does the application run on one server, or as several instances behind a load balancer? And what are you caching: data your code works with, or the complete response that goes back to the client? The table below sums up the differences:

LayerWhere the data livesShared across serversStampede protectionGroup invalidation
IMemoryCacheProcess memory, the object itselfNoNoneVia change tokens
IDistributedCache with RedisRedis, as byte[]YesNoneNone
HybridCacheL1 in the process, L2 in IDistributedCacheL2 onlyYes, per processTags, logical
Response CachingProcess memory, the whole responseNoNoneNone
Output CachingMemory by default, replaceable with RedisWith the Redis storeResource lockingTags, with EvictByTagAsync

On a single server, IMemoryCache is usually enough. With several instances, each one has its own cache, and without sticky sessions a user may get two different answers from two servers; that is where a shared cache becomes necessary. And if you want to keep the whole response of a public endpoint, the right tool is output caching.

IMemoryCache and its memory limit

IMemoryCache stores the object itself, not a serialized copy, so reading from it costs almost nothing. That advantage comes with a trap: every caller gets the same instance, and if one of them modifies a cached list, the cached value has changed for everyone. Cache immutable types and treat what you get back as read-only.

The second issue is memory. According to Microsoft's documentation, ASP.NET Core does not shrink this cache in response to memory pressure, and without SizeLimit it grows without bound. SizeLimit has no unit; you choose the unit yourself, for example one per entry. Once a limit is set, every entry must have a Size, and if the total would go over the limit, the new entry is not stored. The instance that AddMemoryCache registers may be shared by several libraries, and you cannot be sure all of them set a Size on their entries; that is why the documentation warns that putting a limit on the shared cache can break the application. Create a separate instance instead:

using Microsoft.Extensions.Caching.Memory;

public sealed class LookupCache
{
    // SizeLimit has no unit: every entry below has Size = 1, so this caps the entry count.
    public MemoryCache Cache { get; } = new(new MemoryCacheOptions { SizeLimit = 5_000 });
}

// Program.cs
builder.Services.AddSingleton<LookupCache>();

// Inside a service that receives LookupCache lookup, AppDbContext db and a CancellationToken ct
var grades = await lookup.Cache.GetOrCreateAsync("lookup:grades", async entry =>
{
    entry.SetSize(1);
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
    return await db.Grades.Select(g => new GradeDto(g.Id, g.Title)).ToArrayAsync(ct);
});

This code creates a separate cache capped at 5,000 entries and gives every entry a Size and an absolute expiration. Sliding expiration on its own is not enough, because a key that is read constantly never expires. GetOrCreateAsync in IMemoryCache does not prevent concurrent runs of the factory either, so several concurrent requests may each run the query separately.

Redis with IDistributedCache, and HybridCache as a two-level cache

IDistributedCache is the common interface for out-of-process caches, and the Microsoft.Extensions.Caching.StackExchangeRedis package registers its Redis implementation with AddStackExchangeRedisCache. The interface is deliberately simple: a string key and a byte[] value. Serialization, handling misses and preventing stampedes are all up to you. At Gozine2, Redis with Sentinel was part of the infrastructure for exam peaks, and tuning the caching paths was part of my job. The stack there was .NET 6 and HybridCache did not exist yet, so stampede protection had to come from the cache-aside code itself. I describe that pattern and the Sentinel architecture in Backend Notes 1 and will not repeat them here.

HybridCache lives in the Microsoft.Extensions.Caching.Hybrid package, which has had a stable release since March 2025 and also works on .NET 8. It has an L1 layer in process memory and, if an IDistributedCache is registered, uses it as L2. GetOrCreateAsync checks L1 first, then L2, and only if both are empty does it call the factory and write the result to both. For each key, only one of the concurrent callers runs the factory, and the others wait for its result. This coordination is limited to a single HybridCache instance and does not extend to other servers, even if they all use the same Redis.

// dotnet add package Microsoft.Extensions.Caching.Hybrid
// dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;

// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "exams:";                    // key prefix for this app
});

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(10),          // overall lifetime, also sent to Redis (L2)
        LocalCacheExpiration = TimeSpan.FromMinutes(1)  // in-process copy (L1)
    };
});

builder.Services.AddDbContextFactory<ExamDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Exams")));

// ExamCatalog.cs
public sealed class ExamCatalog(HybridCache cache, IDbContextFactory<ExamDbContext> dbFactory)
{
    public ValueTask<ExamSummary?> GetSummaryAsync(int examId, CancellationToken ct) =>
        cache.GetOrCreateAsync(
            $"exam:{examId}:summary",
            async cancel =>
            {
                await using var db = await dbFactory.CreateDbContextAsync(cancel);
                return await db.Exams
                    .Where(e => e.Id == examId)
                    .Select(e => new ExamSummary(e.Id, e.Title, e.StartsAtUtc, e.DurationMinutes))
                    .FirstOrDefaultAsync(cancel);
            },
            tags: [$"exam:{examId}"],
            cancellationToken: ct);

    public ValueTask InvalidateAsync(int examId, CancellationToken ct) =>
        cache.RemoveByTagAsync($"exam:{examId}", ct);
}

The connection string is read from configuration, not from code. Expiration is the overall lifetime of the entry and is also sent to Redis; LocalCacheExpiration is the lifetime of the in-process copy. The factory deliberately does not use the current request's DbContext: its result is shared between concurrent callers, and its cancellation token is canceled only when all the waiting callers have canceled, so it may still be running after the request that started it has finished. A short-lived DbContext from IDbContextFactory removes that dependency.

On the Redis side, the default maxmemory on 64-bit systems is zero, which means there is no memory limit. For a Redis instance that serves only as a cache, set a limit and an eviction policy such as allkeys-lru. The default policy is noeviction, and with it, once memory reaches the limit, commands that write new data fail with an error.

Caching HTTP responses: response caching or output caching

Despite their similar names, these two follow different logic. The response caching middleware, enabled with AddResponseCaching and UseResponseCaching, implements standard HTTP caching semantics: it stores only responses whose Cache-Control header is public, and it honors the request headers too. Browsers usually send Cache-Control: max-age=0 or no-cache on refresh, and the middleware, following RFC 9111, skips the cached response and lets the request through to the application; in other words, the client has the final say. Its only store is memory, and it has neither selective invalidation nor locking. The [ResponseCache] attribute in MVC essentially just sets headers, which also makes it useful for caching in browsers or a CDN; its only option that does not work without the middleware is VaryByQueryKeys.

Output caching, available since .NET 7, defines the caching policy on the server, and the client's request headers cannot override it. Its store is replaceable: memory by default, and since .NET 8 you can swap in Redis with the Microsoft.AspNetCore.OutputCaching.StackExchangeRedis package and the AddStackExchangeRedisOutputCache method. Its entries are invalidated by tag, and it uses resource locking to prevent stampedes. If the response has an ETag and the client sends it back in If-None-Match, or sends If-Modified-Since, and the entry is still fresh, it returns 304. Its defaults are conservative: only 200 responses to GET and HEAD are cached, a response that sets a cookie or answers an authenticated request is not cached, and the default expiration is 60 seconds.

using Microsoft.AspNetCore.OutputCaching;

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("ExamList", policy => policy
        .Expire(TimeSpan.FromSeconds(30))
        .SetVaryByQuery("grade")
        .Tag("exams"));
});

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();                                   // after the auth middleware

app.MapGet("/api/exams", (int grade, ExamCatalog catalog, CancellationToken ct) =>
        catalog.ListAsync(grade, ct))
   .CacheOutput("ExamList");

app.MapPost("/api/admin/exams/{id:int}/publish",
    async (int id, ExamCatalog catalog, IOutputCacheStore store, CancellationToken ct) =>
    {
        await catalog.PublishAsync(id, ct);             // write to SQL Server first
        await catalog.InvalidateAsync(id, ct);          // cached data in HybridCache
        await store.EvictByTagAsync("exams", ct);       // cached HTTP responses
        return Results.NoContent();
    }).RequireAuthorization("ExamAdmin");

In this code, ListAsync and PublishAsync are hypothetical methods on the same service. By default the whole URL, including every query parameter, is part of the key; SetVaryByQuery("grade") limits the query part of the key to that one parameter, so unrelated parameters such as utm parameters do not fragment the cache. Middleware order matters too: according to the documentation, if UseOutputCache comes before authentication and authorization, a request may get content that was cached for an unauthorized user instead of the content meant for an authorized one. This layer also has its own limits, separate from IMemoryCache: SizeLimit in OutputCacheOptions defaults to 100 MB, and a response larger than MaximumBodySize (64 MB by default) is not cached.

Keys and TTLs

A key must include everything that changes the result, and nothing else. A few practical rules:

  • Internal IDs, not raw input. Build keys from internal IDs and from values in a known list. Microsoft's documentation warns that a key built from arbitrary user input is both a security risk and an open door to filling memory with a flood of meaningless keys.
  • A separator between parts. Without one, customer 42 with order 123 and customer 421 with order 23 end up with the same key.
  • A version in the key. If the shape of a DTO changes, add a prefix such as v2:. After a deployment, the old values are still in Redis and may not deserialize correctly into the new class.
  • A prefix per application. InstanceName in AddStackExchangeRedisCache keeps each application's keys apart, so two services on one Redis do not overwrite each other's keys. Keep keys short as well: HybridCache does not cache a key longer than MaximumKeyLength (1024 characters by default).

Choose TTLs by how much staleness each piece of data can tolerate. In HybridCache, according to the documentation, LocalCacheExpiration never goes past the remaining overall lifetime (Expiration) when the value is read from L2. Keep L1 shorter as a rule; as the next section shows, invalidation does not reach the L1 of other servers, so the L1 lifetime is, in practice, the upper bound on staleness on those servers. If many keys are written together, add a little random jitter to their TTLs so they do not all expire at once. Stampede protection in HybridCache is also per process: when a key has expired in L2 as well, ten servers can send up to ten concurrent queries for the same key to the database.

Invalidation: what tags do and do not do

RemoveAsync removes a key from the current server's L1 and from L2. For a group of keys, HybridCache has tags: you pass tags to GetOrCreateAsync, and later RemoveByTagAsync invalidates every entry with that tag. But this invalidation is logical. Since neither IMemoryCache nor IDistributedCache has a concept of tags, RemoveByTagAsync does not delete anything; it only records a rule that "any entry with this tag created before this moment is to be ignored". The next read treats that entry as a miss and calls the factory, and the old values stay in memory and in Redis until they expire normally. RemoveByTagAsync("*") invalidates all HybridCache data, even untagged entries, but patterns such as "exam*" are not supported.

The bigger limitation shows up with multiple servers. According to the documentation, invalidation by key or tag applies to the current server and to L2, but the in-memory caches of other servers do not change. So if server A publishes an exam and invalidates its cache, server B may keep returning the previous version until its LocalCacheExpiration runs out. You have three options: shorten LocalCacheExpiration to the staleness you can accept; for data that must be identical across servers, turn off L1 with HybridCacheEntryFlags.DisableLocalCache; or look at another implementation of the same API. Microsoft's documentation names FusionCache as one such implementation, and according to FusionCache's own documentation, once a backplane is configured, invalidation reaches the other servers too.

In output caching, you attach a tag with Tag in a policy or on an endpoint group, and evict entries from the store with IOutputCacheStore.EvictByTagAsync. But the default store lives in the process's own memory, and according to the documentation each server has its own separate cache; so with several servers, this call clears entries only on the server that received the invalidation request. For shared invalidation, use the Redis store. The documentation advises against using IDistributedCache as the output cache store, because it lacks the atomic operations that tags need.

The cost of serialization, and what not to cache

Every out-of-process cache means serializing on every write and deserializing on every read, plus a network round trip. HybridCache handles strings and byte[] itself and uses System.Text.Json for other types; with AddSerializer or AddSerializerFactory you can plug in another serializer, such as protobuf. According to the documentation, HybridCache keeps the behavior of IDistributedCache-based code by default: every retrieval involves deserialization, and each caller gets its own instance, so a shared object cannot be modified concurrently. If your type is sealed and has the [ImmutableObject(true)] attribute, HybridCache can reuse instances and cut the cost of deserializing on every call. A value larger than MaximumPayloadBytes (1 MB by default) is not stored; it is only logged. Cache small DTOs, not EF Core entities with all their navigations.

And a few things that have no place in a cache:

  • Sensitive per-user data in a shared cache. Tokens, passwords, identity details and anything else you would not want to see in the output of a Redis command. In a shared cache, one mistake in building a key is enough for one user to see another user's data. Output caching does not cache responses to authenticated requests by default; if you lift that default with a custom policy, make sure the response really is the same for everyone, or that the key varies by user.
  • Fast-changing data. When data changes almost as often as it is read, the hit rate is low and the cache only adds one more source of staleness. Examples are counters, live inventory and the time left in a session.
  • The only copy of the data. A cache is a copy, and anything that would be costly to lose has to be written to the database first. A fuller list of these cases for exam systems is in Backend Notes 1.

The decision in short: small, rarely changing data on a single server goes into IMemoryCache with a set limit; data shared across servers goes into HybridCache with Redis as L2 and a short-lived L1; public HTTP responses go to output caching; and response caching is for when you really do want standard HTTP caching semantics.

At NovaTech, too, Redis sits alongside SQL Server and EF Core in the stack of .NET microservices, and the same decisions come up for every service that reads from Redis. Output caching in minimal APIs is also a topic of Backend Notes 3, which comes out on October 19 in the notes section. To measure the effect of caching before a peak, see the Locust load testing guide. If you would like a second look at caching or backend performance in your own system, the Work with me page explains how I can help.

Frequently asked questions

What is the difference between response caching and output caching in ASP.NET Core?

Response caching follows standard HTTP caching semantics: if the client sends Cache-Control: no-cache or max-age=0, the response is generated again, and its only store is memory. Output caching, available since .NET 7, defines the caching policy on the server, has a replaceable store, invalidates entries by tag and uses resource locking to prevent a stampede of concurrent requests.

Does RemoveByTagAsync in HybridCache delete data from the cache?

No. Tag invalidation is logical: it records a rule to ignore entries with that tag if they were created before this moment, and the old values stay in memory and in Redis until they expire normally. According to the documentation, this invalidation does not reach the in-memory cache of other servers.

Does HybridCache work on .NET 8?

Yes. The Microsoft.Extensions.Caching.Hybrid package has had a stable release since March 2025 and works on .NET 8; according to the documentation, it even supports .NET Framework 4.7.2.

What unit is SizeLimit in IMemoryCache measured in?

It has no unit; you define one yourself, for example one unit per entry. Once SizeLimit is set, every entry must have a Size, so put the limit on a separate MemoryCache instance, not on the shared cache that AddMemoryCache registers.

Does HybridCache stampede protection work across multiple servers?

No. Coordination happens only between callers of a single HybridCache instance, even if all servers use the same Redis. So each server runs at most one factory at a time per key, but several servers may run the same factory at the same time.

More in this section

Backend guides