Most EF Core performance work is about making queries cheaper — fixing N+1 patterns, projecting instead of loading entities, adding indexes. The next step is not running the query at all. HybridCache, stabilized in the .NET 9/10 wave as Microsoft.Extensions.Caching.Hybrid, is the caching API that finally makes that safe to do without hand-rolled locking code.
Three things earn it a place in an EF Core application:
- Stampede protection — concurrent requests for the same key run the database query once, not once per caller. This is the failure mode
IMemoryCacheleaves wide open. - Tag-based invalidation — flush every cached query that touches orders with one call, no key bookkeeping.
- Two-level caching — in-process memory (L1) backed by an optional distributed cache (L2, e.g. Redis) behind the same API. You can start L1-only and add Redis later without touching call sites.
Every claim in this article is backed by a statement count you can reproduce — the runnable program is linked at the end.
The Problem with IMemoryCache
The standard pattern looks safe and is not:
// Looks fine. Hides a stampede.
var summaries = await memoryCache.GetOrCreateAsync("orders:summaries", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return await db.Orders
.Select(o => new OrderSummary(o.Reference, o.Customer.Name, o.Lines.Count))
.ToListAsync();
});IMemoryCache.GetOrCreateAsync does not coalesce concurrent callers. When a popular key expires under load, every in-flight request misses, and every one of them runs the factory. Twenty concurrent requests means up to twenty identical queries hitting the database at the same moment — a cache stampede. The cache that was supposed to protect the database instead synchronizes a pile-on against it.
The classic fix is wrapping the factory in a SemaphoreSlim, remembering to release it in a finally, and scoping one semaphore per key so unrelated entries don't serialize each other. Everyone writes this helper once, most versions have a subtle bug, and none of them need to exist anymore.
If you only remember one thing: HybridCache.GetOrCreateAsync guarantees the factory runs
once per key across concurrent callers. That single property replaces the entire
IMemoryCache + SemaphoreSlim pattern.
Setup
One package:
dotnet add package Microsoft.Extensions.Caching.HybridOne registration:
builder.Services.AddHybridCache();That's a fully working cache — L1 (in-process memory) only. Stampede protection and tags work already; nothing below requires Redis.
To add a distributed L2 tier, register any IDistributedCache implementation before AddHybridCache() and HybridCache picks it up automatically:
// Optional L2 — Redis, SQL Server, or Azure Cache all work the same way
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = builder.Configuration.GetConnectionString("Redis"));
builder.Services.AddHybridCache();Reads check L1 first, then L2, then run your factory — and populate both tiers on the way back. L2 buys you cache entries that survive an app restart and are shared across instances behind a load balancer. It changes nothing about the API you call.
Caching an EF Core Query
The domain is the same Order/Customer/OrderLine schema used in the N+1 article, seeded with 500 orders. The query being cached is a projection:
public sealed record OrderSummary(string Reference, string CustomerName, int LineCount);
public class OrderReadService(HybridCache cache, IServiceScopeFactory scopeFactory)
{
public async Task<List<OrderSummary>> GetSummariesAsync(CancellationToken ct = default)
{
return await cache.GetOrCreateAsync(
"orders:summaries", // cache key
async token => await LoadSummariesAsync(token), // runs only on a miss
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
},
tags: ["orders"], // for bulk invalidation
cancellationToken: ct);
}
private async Task<List<OrderSummary>> LoadSummariesAsync(CancellationToken ct)
{
// Fresh scope per execution: the factory can run concurrently with other
// requests, and DbContext is not thread-safe.
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.Orders
.Select(o => new OrderSummary(o.Reference, o.Customer.Name, o.Lines.Count))
.ToListAsync(ct);
}
}Two decisions in that snippet matter more than they look.
Cache projections, never entities
Cached values round-trip through HybridCache's serializer — System.Text.Json by default. A small immutable record serializes cleanly and deserializes into exactly what it was. A tracked entity graph does not: navigation properties drag in half the object model, cycles break serialization outright, and an entity that came out of a cache is not tracked by any DbContext, so mutating it and calling SaveChangesAsync silently does nothing. Project to a DTO at the query, cache the DTO.
This is also why the record has no DbContext anywhere near it. Cache the result of the
query, never anything holding a reference to the context that produced it — the context is
scoped to a request and will be disposed while the cached object lives on.
The factory creates its own scope
The delegate passed to GetOrCreateAsync may execute on whichever caller happens to trigger it. Resolving the DbContext from a fresh scope inside the factory — rather than capturing the request's context — keeps it correct no matter which request wins the race.
What the Counts Look Like
Running the scenarios against 500 seeded orders on SQL Server LocalDB and counting the SQL statements EF Core actually executed:
| Strategy | SQL statements |
|---|---|
| 5 sequential reads, no cache | 5 |
| 5 sequential reads, HybridCache | 1 |
| 20 concurrent reads, no cache | 20 |
| 20 concurrent reads, HybridCache | 1 |
1 read after RemoveByTagAsync("orders") | 1 |

The row that justifies the migration is the concurrent one. Twenty tasks call GetOrCreateAsync for the same cold key at the same time; the factory runs once, nineteen callers await the same execution, and the database sees one query. Replay that scenario with IMemoryCache and the count is 20 — its factory runs once per concurrent caller.
These are counts, not timings, and that is deliberate — they are reproducible on any machine and any relational provider. Locally a query is nearly free, which is exactly why caching looks pointless in development and then matters against a managed database in another region, where every avoided round trip is real latency off a request.
The program that produced them is
samples/ef-core-hybridcache
— it reuses the N+1 article's seed data, runs with or without Redis, and should give you
exactly these numbers.
Invalidation with Tags
Expiration answers "how stale is acceptable?" — invalidation answers "the data just changed." Before tags, invalidation meant tracking every key your writes might affect. With tags, entries declare what they depend on, and writes flush by tag:
// Every cached query that touches orders carries the tag
tags: ["orders"]
// After a write that changes orders:
await db.SaveChangesAsync(ct);
await cache.RemoveByTagAsync("orders", ct);The next read misses, pays one query, and repopulates. That is the last row of the counts table: exactly 1 statement after a tag flush.
Tags compose. A per-customer projection can carry both a broad and a narrow tag:
tags: ["orders", $"customer:{customerId}"]A write touching one customer flushes customer:42 and leaves every other customer's entries warm; a bulk import flushes orders and clears them all. Invalidate at the granularity of the write, not the granularity you happened to key by.
Call RemoveByTagAsync after SaveChangesAsync succeeds, not before. Flush first and a
concurrent reader can repopulate the cache with pre-save data that then lives until
expiration — the exact staleness you were trying to prevent. Flushing after keeps the
window to the instant between commit and flush, and expiration still backstops it.
Choosing Expiration
Stampede protection changes the economics of short expirations. With IMemoryCache, a short TTL on a hot key meant a stampede at every expiry, so TTLs crept upward to compensate. With HybridCache, an expiry costs exactly one query no matter how many requests are in flight — so you can afford honest, short expirations and lean on tags for correctness:
| Data | Expiration | Invalidation |
|---|---|---|
| Reference data (currencies, categories) | Hours | Tag flush on the rare write |
| List/summary queries (this article's case) | 1–5 minutes | Tag flush after writes |
| Per-user data | Minutes | Tag per user (user:{id}) |
| Anything feeding an authorization decision | Don't cache it | — |
HybridCacheEntryOptions also has LocalCacheExpiration for the L1 copy specifically — useful when L2 is shared across instances but you want each instance's in-process copy to revalidate sooner.
When Not to Use It
The same honesty that applies to query optimization applies here: caching is not a default, it is a trade of freshness for load.
- Don't cache what you read once. A cache in front of a query that runs once per hour is bookkeeping with no payoff.
- Don't cache per-request data. If the key would need the request id in it, the cache is a dictionary with extra steps.
- Don't cache authorization inputs. A permission revoked five minutes ago that still authorizes requests is an incident, not a performance win.
- Watch the write-to-read ratio. Data written as often as it is read spends its life invalidated; you pay serialization on every miss and gain nothing.
Checklist
- Register
AddHybridCache()— L1-only is a complete, correct starting point - Cache projections (records/DTOs), never tracked entities or anything referencing a
DbContext - Create a fresh scope inside the factory — it runs on whichever caller triggers it
- Tag every entry with the data it depends on (
orders,customer:{id}) RemoveByTagAsyncafterSaveChangesAsync, not before- Keep expirations short — stampede protection makes expiry cheap, and expiration backstops missed invalidations
- Add Redis L2 when you scale out — registration change only, no call-site changes
Summary
| Problem | Solution |
|---|---|
| Same query executed by every request | HybridCache.GetOrCreateAsync with a key |
| Cache stampede on expiry under load | Built-in — factory runs once per key |
| Invalidating without key bookkeeping | tags: [...] + RemoveByTagAsync |
| Cache lost on restart / not shared across instances | Register an IDistributedCache (Redis) as L2 |
| Cached entity graphs misbehaving | Cache projection records instead |
Hand-rolled SemaphoreSlim cache helpers | Delete them |
The migration path is incremental: replace one hot, read-heavy query's IMemoryCache usage with HybridCache, verify the statement counts drop the way the table above says they should, and expand from there. The API is the same shape you already know — it just closes the traps the old one left open.
Further reading
- HybridCache library in ASP.NET Core (Microsoft Learn)
- Distributed caching in ASP.NET Core (Microsoft Learn)
- Solving the N+1 query problem — make the query cheap before you cache it