//JorgenHoc
← All articles
EF CoreBy Jorge Calderón10 min read

Caching EF Core Queries with HybridCache in .NET 10

Cache EF Core query results with .NET 10's HybridCache: stampede protection, tag-based invalidation, and optional Redis L2 — with measured SQL statement counts.

#entity-framework#dotnet#performance#caching

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:

  1. Stampede protection — concurrent requests for the same key run the database query once, not once per caller. This is the failure mode IMemoryCache leaves wide open.
  2. Tag-based invalidation — flush every cached query that touches orders with one call, no key bookkeeping.
  3. 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.Hybrid

One 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:

StrategySQL statements
5 sequential reads, no cache5
5 sequential reads, HybridCache1
20 concurrent reads, no cache20
20 concurrent reads, HybridCache1
1 read after RemoveByTagAsync("orders")1
Console output of the HybridCache sample: 5 sequential reads cost 5 SQL statements without a cache and 1 with HybridCache; 20 concurrent reads cost 20 statements without a cache and 1 with HybridCache; one read after RemoveByTagAsync costs 1 statement.
The table above, straight from the console — same run, nothing retyped.

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:

DataExpirationInvalidation
Reference data (currencies, categories)HoursTag flush on the rare write
List/summary queries (this article's case)1–5 minutesTag flush after writes
Per-user dataMinutesTag per user (user:{id})
Anything feeding an authorization decisionDon'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

  1. Register AddHybridCache() — L1-only is a complete, correct starting point
  2. Cache projections (records/DTOs), never tracked entities or anything referencing a DbContext
  3. Create a fresh scope inside the factory — it runs on whichever caller triggers it
  4. Tag every entry with the data it depends on (orders, customer:{id})
  5. RemoveByTagAsync after SaveChangesAsync, not before
  6. Keep expirations short — stampede protection makes expiry cheap, and expiration backstops missed invalidations
  7. Add Redis L2 when you scale out — registration change only, no call-site changes

Summary

ProblemSolution
Same query executed by every requestHybridCache.GetOrCreateAsync with a key
Cache stampede on expiry under loadBuilt-in — factory runs once per key
Invalidating without key bookkeepingtags: [...] + RemoveByTagAsync
Cache lost on restart / not shared across instancesRegister an IDistributedCache (Redis) as L2
Cached entity graphs misbehavingCache projection records instead
Hand-rolled SemaphoreSlim cache helpersDelete 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

About the author

Jorge Calderón

Software engineer with over a decade building and operating .NET applications in production — EF Core data layers, async-heavy services, and Azure and container deployments. Every benchmark and sample project in these guides is published in a public GitHub repository so you can rerun it yourself.

GitHub profileLinkedIn ↗Benchmarks & sample code

Related articles