What's New in Caching with .NET 11: MemoryCache Metrics, HybridCache Factory Context and IOutputCachePolicyProvider
.NET 11 brings three caching changes: with TrackStatistics on, MemoryCache publishes dotnet.cache.requests, evictions, entries and estimated_size metrics on its own; the HybridCache factory can choose the duration from the value; and IOutputCachePolicyProvider resolves output cache policies at runtime. The catch we measured: with today's package the new HybridCache overload isn't atomic (40 factory runs instead of 20 for 20 cold keys), and a custom provider silently drops AddPolicy definitions.
Version note: The code in this post was built and run with .NET 10 and .NET 11 RC (Release Candidate) 3; the .NET 11 examples ran with the 11.0.100-rc.3.26522.148 SDK (Software Development Kit), Microsoft.Extensions.Caching.Memory 11.0.0-rc.3, Microsoft.Extensions.Caching.Hybrid 10.11.0, OpenTelemetry 1.20.1 and Redis 8.2. The GA (General Availability) release of .NET 11 is expected on 10 November 2026; re-check the behavior described here after GA.
Guess First
Bookstore's book details are cached with HybridCache, and the logs show odd traffic: a bot is trying non-existent book IDs one by one. Every attempt misses the cache and hits the database. You want to cache the "no such book" answer, but for a few seconds rather than 30 minutes, because that book may be added to the catalog five minutes later. In .NET 10 you had to supply the entry options before the factory ran, so the duration couldn't depend on the value. In .NET 11 the factory gets a context: if the book is missing, you write context.Expiration = TimeSpan.FromSeconds(5) and you're done.
Two questions. First: there are 20 cold keys, and for each one 64 concurrent workers send five requests in a row. With the old overload the repository was called exactly 20 times, once per key. How many times is it called with the new overload? Second: you registered your own IOutputCachePolicyProvider for output caching. Does the policy you defined inside AddOutputCache with AddPolicy("legacy", ...) keep working?
Looking Back: Cache APIs from .NET 7 to .NET 10
Since this is the last post in the series, let's first look back over four years. .NET 7 (November 2022) brought two things: the output cache middleware in ASP.NET Core, and TrackStatistics plus GetCurrentStatistics() on MemoryCache. The first was introduced in the ASP.NET Core Web API (Application Programming Interface) series post "What's New in ASP.NET Core with .NET 7: MapGroup, TypedResults, Rate Limiting and Output Caching". The second kept the counters, but moving them into a metrics system was your job.
.NET 8 (November 2023) added a Redis store to output caching: with the Microsoft.AspNetCore.OutputCaching.StackExchangeRedis package (8.0.0, 14 November 2023) and AddStackExchangeRedisOutputCache, cached responses could be shared across servers (the sixth post in this series). .NET 9 brought HybridCache; the Microsoft.Extensions.Caching.Hybrid package was still in preview when .NET 9 shipped, and the first stable version, 9.3.0, was released on 11 March 2025. The Web API series post "What's New in .NET 9: Built-in OpenAPI, Scalar and HybridCache in ASP.NET Core" introduced it, and the fourth post in this series went deep. This series itself was built on .NET 10 LTS (Long Term Support).
Throughout the series we kept running into three gaps: you wrote your own counters to see the in-memory cache's hit ratio, you couldn't pick a duration based on the value in HybridCache, and output cache policies were fixed at startup. .NET 11 answers all three; we'll run each one and measure its limits.
MemoryCache Metrics: TrackStatistics Now Speaks OpenTelemetry
In .NET 11, when TrackStatistics is on, MemoryCache publishes four observable instruments through a meter named Microsoft.Extensions.Caching.Memory.MemoryCache. No extra package, no adapter. The shortest way to see the measurements without setting up an exporter is a MeterListener:
static List<string> Collect(bool showScope = false)
{
List<string> lines = [];
using MeterListener listener = new();
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter.Name == MeterName)
l.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
{
string tagText = string.Join(",", tags.ToArray().Select(t => $"{t.Key}={t.Value}"));
string scope = showScope ? $" scope={instrument.Meter.Scope?.GetType().Name ?? "null"}" : "";
lines.Add($"{instrument.Name,-27} {value,3} {tagText}{scope}");
});
listener.Start();
listener.RecordObservableInstruments();
return lines;
}
First a book cache: three entries, a fourth that lives for 50 ms, two hits, one miss, one manual removal and one read of the expired entry. For comparison we also looked once at a cache with statistics turned off:
// 1) TrackStatistics off: no measurements at all
using (MemoryCache silent = new(new MemoryCacheOptions()))
{
silent.Set("k", 1);
Console.WriteLine($"TrackStatistics=false -> {Collect().Count} measurements");
}
// 2) TrackStatistics on, no SizeLimit
using MemoryCache books = new(new MemoryCacheOptions { TrackStatistics = true, Name = "books" });
for (int i = 1; i <= 3; i++) books.Set($"book:{i}", $"Book {i}");
books.Set("book:4", "Book 4", TimeSpan.FromMilliseconds(50));
books.TryGetValue("book:1", out _);
books.TryGetValue("book:2", out _);
books.TryGetValue("book:9", out _);
books.Remove("book:3"); // a manual remove is not an eviction
Thread.Sleep(100);
books.TryGetValue("book:4", out _); // expired: miss + eviction
Print("books (no SizeLimit)", Collect(), "books");
TrackStatistics=false -> 0 measurements
--- books (no SizeLimit)
dotnet.cache.requests 2 dotnet.cache.name=books,dotnet.cache.request.result=hit
dotnet.cache.requests 2 dotnet.cache.name=books,dotnet.cache.request.result=miss
dotnet.cache.evictions 1 dotnet.cache.name=books
dotnet.cache.entries 2 dotnet.cache.name=books
With TrackStatistics off there isn't a single measurement; this is opt-in. The dotnet.cache.requests counter separates hits from misses with the dotnet.cache.request.result tag. Reading the expired book:4 counted both a miss and an eviction; the manually removed book:3 didn't count, because in the source the counter only increases on the expiration and compaction paths. dotnet.cache.entries is the current entry count. The fourth instrument is missing because we didn't set a SizeLimit:
// 3) with SizeLimit, estimated_size is published too
using MemoryCache covers = new(new MemoryCacheOptions { TrackStatistics = true, Name = "covers", SizeLimit = 100 });
covers.Set("cover:1", new byte[10], new MemoryCacheEntryOptions { Size = 40 });
covers.Set("cover:2", new byte[10], new MemoryCacheEntryOptions { Size = 40 });
covers.Set("cover:3", new byte[10], new MemoryCacheEntryOptions { Size = 40 }); // exceeds the limit, not added
covers.TryGetValue("cover:3", out _);
Print("covers (SizeLimit=100)", Collect(), "covers");
covers.Compact(0.5); // compaction counts as an eviction
Print("covers after Compact(0.5)", Collect(), "covers");
--- covers (SizeLimit=100)
dotnet.cache.requests 0 dotnet.cache.name=covers,dotnet.cache.request.result=hit
dotnet.cache.requests 1 dotnet.cache.name=covers,dotnet.cache.request.result=miss
dotnet.cache.evictions 0 dotnet.cache.name=covers
dotnet.cache.entries 2 dotnet.cache.name=covers
dotnet.cache.estimated_size 80 dotnet.cache.name=covers
--- covers after Compact(0.5)
dotnet.cache.requests 0 dotnet.cache.name=covers,dotnet.cache.request.result=hit
dotnet.cache.requests 1 dotnet.cache.name=covers,dotnet.cache.request.result=miss
dotnet.cache.evictions 1 dotnet.cache.name=covers
dotnet.cache.entries 1 dotnet.cache.name=covers
dotnet.cache.estimated_size 40 dotnet.cache.name=covers
dotnet.cache.estimated_size is published only when SizeLimit is set, and its unit is "1": the value isn't bytes but the sum of the Size units you gave the entries. With a limit of 100 the third cover was never added (40 + 40 + 40 > 100); the entry count stayed at 2, the estimated size at 80, and the rejected add wasn't counted as an eviction. Compact(0.5) dropped one entry, and that showed up in the eviction counter. The mechanics of SizeLimit and compaction are in the second post of the series.
IMeterFactory, Name and Wiring Up OpenTelemetry
In a real application you don't write a MeterListener; you hand the meter name to OpenTelemetry (OTel). In the Bookstore API we gave a name to the IMemoryCache that also serves as the L1 (level 1, in-process cache) of HybridCache, and turned statistics on:
builder.Services.AddMemoryCache(o =>
{
o.TrackStatistics = true;
o.Name = "catalog-l1";
});
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:17560");
builder.Services.AddHybridCache();
builder.Services.AddSingleton<BookRepository>();
builder.Services.AddSingleton<BookCatalog>();
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics
.AddMeter("Microsoft.Extensions.Caching.Memory.MemoryCache")
.AddConsoleExporter((_, reader) => reader.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 5000));
After calling /api/books/42 10 times and the non-existent /api/books/5001 3 times, this is the relevant block from the console exporter's output:
Metric Name: dotnet.cache.requests, Description: Total cache requests., Unit: {request}, Metric Type: LongSum
Instrumentation scope (Meter):
Name: Microsoft.Extensions.Caching.Memory.MemoryCache
Tags:
dotnet.cache.name: catalog-l1
(2026-11-08T02:14:11.0191630Z, 2026-11-08T02:14:22.9168960Z] dotnet.cache.name: catalog-l1 dotnet.cache.request.result: hit
Value: 11
(2026-11-08T02:14:11.0191630Z, 2026-11-08T02:14:22.9168960Z] dotnet.cache.name: catalog-l1 dotnet.cache.request.result: miss
Value: 2
The most practical result of this section: the default HybridCache implementation uses the IMemoryCache from DI (Dependency Injection) as its L1, so the L1 hit ratio reaches your dashboard without a single extra line of code. 11 of the 13 requests were served from L1 and 2 went to the factory. L2 (level 2, an out-of-process shared cache such as Redis) hits don't appear in this metric; they count as L1 misses. The output cache's default store creates its own MemoryCache with only SizeLimit set, so its traffic doesn't reach this meter either.
There are two more new pieces. MemoryCacheOptions.Name (default "Default") is added to every measurement as the dotnet.cache.name tag. The new MemoryCache(IOptions<MemoryCacheOptions>, ILoggerFactory?, IMeterFactory?) constructor creates the meter through IMeterFactory; without a factory, the instruments attach to a single meter shared across the process. Because DI picks a constructor based on the parameters it can resolve, there's a small trap; ASP.NET Core is fine, but in a plain ServiceCollection:
// 4) DI: AddMemoryCache picks the IMeterFactory constructor
ServiceCollection services = new();
services.AddLogging();
services.AddMetrics();
services.AddMemoryCache(o => { o.TrackStatistics = true; o.Name = "catalog-l1"; });
using ServiceProvider sp = services.BuildServiceProvider();
IMemoryCache l1 = sp.GetRequiredService<IMemoryCache>();
l1.Set("menu", "Fiction");
l1.TryGetValue("menu", out _);
Print("DI catalog-l1", Collect(showScope: true), "catalog-l1");
# without AddLogging()
dotnet.cache.requests 1 dotnet.cache.name=catalog-l1,dotnet.cache.request.result=hit scope=null
# with AddLogging()
dotnet.cache.requests 1 dotnet.cache.name=catalog-l1,dotnet.cache.request.result=hit scope=DefaultMeterFactory
Without AddLogging(), ILoggerFactory couldn't be resolved, DI fell back to the single-parameter constructor and the cache attached to the shared meter. Measurements still arrive, but you lose the meter-level tags and the lifetime managed by the factory. One more note: the hit/miss tag was named dotnet.cache.request.type in preview builds and is now dotnet.cache.request.result; if you built a dashboard during the previews, update your filter.
You don't have to wait for .NET 11 to get these metrics. We added the Microsoft.Extensions.Caching.Memory 11.0.0-rc.3 package to an ASP.NET Core app on .NET 10; the package's assembly took precedence over the framework's:
runtime 10.0.13, Caching.Memory 11.0.0.0
dotnet.cache.requests 1 dotnet.cache.name=net10-l1,dotnet.cache.request.result=hit scope=DefaultMeterFactory
In production you'd do this with the GA 11.0.0 package, not the RC.
HybridCache Factory Context: Choosing the Duration from the Value
HybridCache gained two new GetOrCreateAsync overloads. In both, the factory receives a HybridCacheEntryContext while producing the value and can change how the entry is cached after seeing the value. Bookstore's book catalog:
// stands in for the database: 100 ms latency, counts calls
public sealed class BookRepository
{
private int _calls;
public int Calls => Volatile.Read(ref _calls);
public async Task<Book?> FindAsync(int id, CancellationToken ct)
{
Interlocked.Increment(ref _calls);
await Task.Delay(100, ct);
return id <= 1000 ? new Book(id, $"Book {id}", 10m + id % 50) : null;
}
}
public sealed class BookCatalog(HybridCache cache, BookRepository repository)
{
private static readonly HybridCacheEntryOptions Defaults = new()
{
Expiration = TimeSpan.FromMinutes(30),
LocalCacheExpiration = TimeSpan.FromMinutes(5)
};
public ValueTask<Book?> GetAsync(int id, CancellationToken ct = default) =>
cache.GetOrCreateAsync(
$"v1:book:{id}",
(repository, id),
static async (state, context, token) =>
{
Book? book = await state.repository.FindAsync(state.id, token);
if (book is null)
{
// negative caching: a missing book is remembered for 5 seconds only
context.Expiration = TimeSpan.FromSeconds(5);
context.LocalCacheExpiration = TimeSpan.FromSeconds(5);
}
return book;
},
Defaults,
cancellationToken: ct);
// the classic .NET 10 overload: duration independent of the value
public ValueTask<Book?> GetClassicAsync(int id, CancellationToken ct = default) =>
cache.GetOrCreateAsync(
$"v1:book-classic:{id}",
(repository, id),
static (state, token) => new ValueTask<Book?>(state.repository.FindAsync(state.id, token)),
Defaults,
cancellationToken: ct);
}
book 42 x3 -> repository calls: 1
book 5001 x3 -> repository calls: 2
after 6 s -> repository calls: 3
An existing book stays for 30 minutes (5 in L1), a missing one for 5 seconds: after 6 seconds book 42 still came from the cache, while the repository was called a third time for 5001. HybridCache caches null values too; that's why negative caching needs no separate "missing" marker. The new signatures are GetOrCreateAsync<TState, T>(string key, TState state, Func<TState, HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory, ...) and the stateless GetOrCreateAsync<T>(string key, Func<HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory, ...). The context's properties and how they behave today:
| Property | What it does | What we measured with Hybrid 10.11.0 |
|---|---|---|
Expiration | Total lifetime of the entry (the duration sent to L2) | Works |
LocalCacheExpiration | Lifetime in L1 | Works |
Flags | Disable reads/writes with HybridCacheEntryFlags | Works; with both write flags set, the result is written to no layer |
LocalSize | The Size of the L1 entry (new, also on HybridCacheEntryOptions) | Ignored |
Revision | Counter that increases whenever the factory changes a value (read-only) | For implementations |
The Flags row matches the third scenario listed in the API proposal in dotnet/runtime: on a transient failure, return a fallback value without throwing and don't cache it.
HybridCache cache = sp.GetRequiredService<HybridCache>();
int runs = 0;
for (int i = 0; i < 3; i++)
await cache.GetOrCreateAsync("v1:price:9", (context, ct) =>
{
runs++;
// transient failure: write the result to no layer
context.Flags = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite;
return ValueTask.FromResult(-1m);
});
Console.WriteLine($"flags via context: factory runs={runs} of 3");
flags via context: factory runs=3 of 3
$ docker exec chwork-redis-e redis-cli DBSIZE
0
One more detail: the default implementation seeds the context from the options you pass to the call. Global defaults set with AddHybridCache(o => o.DefaultEntryOptions = ...) aren't visible in the context; if you don't pass options, context.Expiration starts as null. That's why the example passes Defaults explicitly.
The Current Cost of the Factory Context: Not Atomic
The new overload is a virtual method with a body on the HybridCache abstract class. The default body in dotnet/runtime calls the regular GetOrCreateAsync with both write flags disabled, then performs a separate SetAsync with whatever the factory left in the context; a comment in the source admits this opens a race condition. DefaultHybridCache in Microsoft.Extensions.Caching.Hybrid 10.11.0 doesn't override the method, so today you're running that body. Let's measure:
// 20 cold keys; 64 workers per key, each calling 5 times in a row
foreach ((string name, Func<int, ValueTask<Book?>> get) in new (string, Func<int, ValueTask<Book?>>)[]
{ ("classic", id => catalog.GetClassicAsync(id)), ("context", id => catalog.GetAsync(id)) })
{
int before = repo.Calls;
for (int id = 1; id <= 20; id++)
{
await Task.WhenAll(Enumerable.Range(0, 64).Select(async _ =>
{
for (int i = 0; i < 5; i++) await get(id);
}));
}
Console.WriteLine($"{name,-8} 20 keys x 64 workers x 5 -> repository calls: {repo.Calls - before}");
}
Three runs with L1 only, then three with a Redis L2:
# no L2
classic 20 keys x 64 workers x 5 -> repository calls: 20
context 20 keys x 64 workers x 5 -> repository calls: 40
classic 20 keys x 64 workers x 5 -> repository calls: 20
context 20 keys x 64 workers x 5 -> repository calls: 40
classic 20 keys x 64 workers x 5 -> repository calls: 20
context 20 keys x 64 workers x 5 -> repository calls: 40
# Redis L2
classic 20 keys x 64 workers x 5 -> repository calls: 20
context 20 keys x 64 workers x 5 -> repository calls: 31
classic 20 keys x 64 workers x 5 -> repository calls: 20
context 20 keys x 64 workers x 5 -> repository calls: 34
classic 20 keys x 64 workers x 5 -> repository calls: 20
context 20 keys x 64 workers x 5 -> repository calls: 29
The answer to the guess: 40 on every run without L2, two per key; between 29 and 34 with Redis. In the first wave the 64 workers join the same inner call and the factory runs once; that part is still stampede-protected. But because the value is handed out before it's written, the workers start their second request before the leader's SetAsync finishes, find the cache empty and run the factory once more. From the third request on, everything hits; with Redis the timing mixes with L2 reads, so the number moves around. In real traffic that's one extra database query for every cold key and every expiration; the full picture of stampede is in the eighth post of the series.
The same cause applies to LocalSize. We bounded L1 with SizeLimit and set context.LocalSize = 1 in the factory:
L1 entries=3 estimatedSize=108
Three entries took 108 units, 36 per entry: the byte length of the Book record's JSON (JavaScript Object Notation) payload. The 10.11.0 package doesn't know LocalSize and computes the L1 size itself. The pull request that closes both gaps by adding an override to DefaultHybridCache is open in dotnet/extensions (#7568); as of 8 November 2026 it hasn't been merged. Until that release arrives the rule is simple: use the new overload only for keys whose duration truly depends on the value and that aren't hot (negative caching is exactly that), and stay on the classic overload for hot keys.
Resolving Policies at Runtime with IOutputCachePolicyProvider
Up to .NET 10, named policies were fixed at startup with AddOutputCache(options => options.AddPolicy(...)). There wasn't even a way to check from outside whether a policy name existed; the issue that raised this need was opened for YARP (Yet Another Reverse Proxy), which reads its routes from configuration. .NET 11 brings ASP.NET Core the provider model you already know from authorization and CORS (Cross-Origin Resource Sharing):
public interface IOutputCachePolicyProvider
{
IReadOnlyList<IOutputCachePolicy> GetBasePolicies();
ValueTask<IOutputCachePolicy?> GetPolicyAsync(string policyName);
}
Bookstore serves a multi-tenant storefront, and cache durations live in a database table: the "catalog" policy is 60 seconds for acme, 0 for globex (globex wants live prices) and 60 seconds for everyone else. In the example a CachePolicyStore with 20 ms latency and a load counter stands in for that table. The provider and two policies:
public sealed class TenantOutputCachePolicyProvider(CachePolicyStore store) : IOutputCachePolicyProvider
{
private static readonly IReadOnlyList<IOutputCachePolicy> BasePolicies = [new VaryByTenantPolicy()];
private readonly ConcurrentDictionary<string, Task<IOutputCachePolicy?>> _policies = new();
private int _lookups;
public int Lookups => Volatile.Read(ref _lookups);
public IReadOnlyList<IOutputCachePolicy> GetBasePolicies() => BasePolicies;
public ValueTask<IOutputCachePolicy?> GetPolicyAsync(string policyName)
{
Interlocked.Increment(ref _lookups);
// the middleware asks several times per request: remember the result
return new ValueTask<IOutputCachePolicy?>(_policies.GetOrAdd(policyName, LoadAsync));
}
private async Task<IOutputCachePolicy?> LoadAsync(string name) =>
await store.LoadAsync(name) is { } settings ? new TenantSettingsPolicy(settings) : null;
}
// adds the tenant to every request's cache key; doesn't enable caching by itself
public sealed class VaryByTenantPolicy : IOutputCachePolicy
{
public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
context.CacheVaryByRules.VaryByValues["tenant"] = Tenant.From(context.HttpContext);
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;
}
public sealed class TenantSettingsPolicy(CachePolicySettings settings) : IOutputCachePolicy
{
public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
string tenant = Tenant.From(context.HttpContext);
TimeSpan duration = settings.TenantDurations.GetValueOrDefault(tenant, settings.DefaultDuration);
HttpRequest request = context.HttpContext.Request;
// we re-implement the default policy's rules: GET/HEAD only, anonymous requests
bool cacheable = duration > TimeSpan.Zero
&& (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method))
&& StringValues.IsNullOrEmpty(request.Headers.Authorization)
&& context.HttpContext.User.Identity?.IsAuthenticated != true;
context.EnableOutputCaching = true;
context.AllowCacheLookup = cacheable;
context.AllowCacheStorage = cacheable;
context.AllowLocking = true;
context.ResponseExpirationTimeSpan = duration;
context.CacheVaryByRules.QueryKeys = "*";
context.Tags.Add($"tenant:{tenant}");
context.Tags.Add(settings.Name);
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
HttpResponse response = context.HttpContext.Response;
if (response.StatusCode != StatusCodes.Status200OK || !StringValues.IsNullOrEmpty(response.Headers.SetCookie))
context.AllowCacheStorage = false;
return ValueTask.CompletedTask;
}
}
Tenant.From is a two-line helper that reads the X-Tenant header; in a real app you'd take the tenant from the host name or a claim in the token. Registration and endpoints:
builder.Services.AddResponseCompression();
builder.Services.AddOutputCache(options =>
options.AddPolicy("legacy", b => b.Expire(TimeSpan.FromSeconds(30))));
builder.Services.AddSingleton<CachePolicyStore>();
builder.Services.AddSingleton<TenantOutputCachePolicyProvider>();
builder.Services.AddSingleton<IOutputCachePolicyProvider>(sp => sp.GetRequiredService<TenantOutputCachePolicyProvider>());
WebApplication app = builder.Build();
app.UseResponseCompression();
app.UseOutputCache();
app.MapGet("/api/catalog/menu", (HttpContext http, int page = 1) =>
new { tenant = Tenant.From(http), page, generatedAt = DateTime.UtcNow.ToString("HH:mm:ss.fff") })
.CacheOutput("catalog");
app.MapGet("/api/legacy", () => new { generatedAt = DateTime.UtcNow.ToString("HH:mm:ss.fff") })
.CacheOutput("legacy");
app.MapPost("/api/admin/tenants/{tenant}/evict", async (string tenant, IOutputCacheStore store, CancellationToken ct) =>
{
await store.EvictByTagAsync($"tenant:{tenant}", ct);
return Results.NoContent();
});
$ curl -s -H "X-Tenant: acme" localhost:17561/api/catalog/menu
{"tenant":"acme","page":1,"generatedAt":"02:14:10.917"}
$ curl -s -H "X-Tenant: acme" localhost:17561/api/catalog/menu
{"tenant":"acme","page":1,"generatedAt":"02:14:10.917"}
$ curl -s -H "X-Tenant: initech" localhost:17561/api/catalog/menu
{"tenant":"initech","page":1,"generatedAt":"02:14:10.935"}
$ curl -s -H "X-Tenant: globex" localhost:17561/api/catalog/menu
{"tenant":"globex","page":1,"generatedAt":"02:14:10.940"}
$ curl -s -H "X-Tenant: globex" localhost:17561/api/catalog/menu
{"tenant":"globex","page":1,"generatedAt":"02:14:10.945"}
$ curl -s localhost:17561/debug/policies
{"lookups":8,"loads":1}
$ curl -s -X POST localhost:17561/api/admin/tenants/acme/evict -w "%{http_code}\n"
204
$ curl -s -H "X-Tenant: acme" localhost:17561/api/catalog/menu
{"tenant":"acme","page":1,"generatedAt":"02:14:10.961"}
$ curl -s -H "X-Tenant: initech" localhost:17561/api/catalog/menu
{"tenant":"initech","page":1,"generatedAt":"02:14:10.935"}
$ curl -s localhost:17561/api/legacy
{"generatedAt":"02:14:10.994"}
$ curl -s localhost:17561/api/legacy
{"generatedAt":"02:14:11.000"}
$ curl -s localhost:17561/debug/policies
{"lookups":14,"loads":2}
acme's second request came from the cache, initech got its own copy, globex was regenerated every time; evicting acme's tag refreshed only acme. GetBasePolicies is called on every request and takes no HttpContext; that's why the tenant decision lives inside the policy, through OutputCacheContext. GetPolicyAsync, on the other hand, is asked again at every stage of a named policy: twice for a cacheable request (CacheRequestAsync, then ServeFromCacheAsync or ServeResponseAsync), once for a request with storage disabled, like globex. That's where the 8 lookups for five requests come from; the store was hit only once.
The answer to the second guess is in the output too: legacy wasn't cached, the two requests returned different timestamps and loads went up to 2. It was the default provider that read what you defined with AddPolicy and AddBasePolicy; the moment you replace it, those definitions silently drop out. You can't wrap and delegate to the default provider either, because DefaultOutputCachePolicyProvider is internal. Building policies with the fluent builder doesn't work:
BuilderProbe.cs(8,13): error CS1729: 'OutputCachePolicyBuilder' does not contain a constructor that takes 0 arguments
The constructors and the Build() method of OutputCachePolicyBuilder are internal; every policy your provider returns is an IOutputCachePolicy you write yourself. The sample on Microsoft Learn registers the provider with AddTransient after AddOutputCache; we chose a singleton because we keep a dictionary. Since the middleware takes the provider once in its constructor, a scoped registration isn't appropriate.
Response Compression Now Adds Vary to Every Response
There's one more change that sits in the same pipeline as output caching. We ran the same small app on .NET 10 and .NET 11:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddResponseCompression();
WebApplication app = builder.Build();
app.UseResponseCompression();
app.MapGet("/api/books", () => Enumerable.Range(1, 50).Select(i => new { id = i, title = $"Book {i}" }));
app.Run();
# .NET 10 (17563): no Accept-Encoding, then gzip
HTTP/1.1 200 OK
HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding
# .NET 11 (17564): no Accept-Encoding, then gzip
HTTP/1.1 200 OK
Vary: Accept-Encoding
HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding
On .NET 10 the uncompressed response had no Vary; a shared cache or CDN (Content Delivery Network) in between could store it as the same for everyone. With compression enabled, .NET 11 adds Vary: Accept-Encoding to every response. How HTTP (Hypertext Transfer Protocol) caches use Vary is covered in the fifth post of the series. In the same release Zstandard joined the default providers: for a client sending Accept-Encoding: zstd, br, gzip, .NET 10 returned br and .NET 11 returned zstd.
Production Notes
Start with the calendar: everything here was measured on RC 3. After GA, check the metric names and whether DefaultHybridCache overrides the new overload in the package release notes.
The cost of TrackStatistics is low: in the source, hit/miss counters are kept per thread, and they're summed only when the exporter asks for a measurement. Chart the hit ratio per dotnet.cache.name as hit / (hit + miss), compare estimated_size with SizeLimit, and read sudden jumps in evictions as a signal about your TTL (Time to Live) or limit settings. Reading them alongside p95 and database load is the subject of the ninth post in the series.
GetBasePolicies runs on the hot path of every request; return a static list that doesn't allocate. Remember the result of GetPolicyAsync, but when the settings table changes, clear the dictionary and evict the related tags; otherwise the new duration only takes effect after a restart.
Common Pitfalls
Forgetting the vary rules in a custom policy. In a copy where we removed VaryByTenantPolicy from the base policy list and deleted the QueryKeys = "*" line:
$ curl -s -H "X-Tenant: acme" "localhost:17562/api/catalog/menu?page=1"
{"tenant":"acme","page":1,"generatedAt":"02:13:06.368"}
$ curl -s -H "X-Tenant: acme" "localhost:17562/api/catalog/menu?page=2"
{"tenant":"acme","page":1,"generatedAt":"02:13:06.368"}
$ curl -s -H "X-Tenant: initech" "localhost:17562/api/catalog/menu?page=1"
{"tenant":"acme","page":1,"generatedAt":"02:13:06.368"}
Page two got page one, and initech got acme's menu. The default policy added the query string to the key on its own; once you write your own policy, that's your job too. Leaving the tenant out of the key isn't a performance bug, it's a data leak between tenants.
Losing the default safety rules. The rules that only cache 200 responses, only GET/HEAD, and skip requests with an Authorization header and responses that set Set-Cookie live in the internal DefaultPolicy. If your policy doesn't re-implement them, a user-specific response can be served to everyone.
Remembering a failed load forever. Because we put a Task into the provider's ConcurrentDictionary, if the database fails once, the faulted Task stays there. In production, remove failed loads from the dictionary or use a cache with an expiration.
Treating estimated_size as bytes, and giving several caches the same Name. The first is in your own Size units; with the second, the measurements of two caches mix under the same tag and the hit ratio becomes meaningless.
A Small Experiment
★☆☆ In the metrics example, set the covers cache's SizeLimit to 50 and change CompactionPercentage. How do estimated_size, entries and evictions change? Can you spot rejected adds from these three metrics?
★★☆ Add a second rule to BookCatalog: if the repository throws TimeoutException, the factory catches it, returns null and uses Flags to write the result to no layer. Make the repository time out on one call in three and repeat the race measurement: how do the numbers change?
★★★ Extend TenantOutputCachePolicyProvider to notice changes in the settings table every 30 seconds: remove the changed policy from the dictionary and call EvictByTagAsync with that policy's tag. When you raise globex's duration from 0 to 10 seconds, does it start caching without restarting the app?
This post completes the series. We started with "measure first, then cache" and walked through the layers one by one: from IMemoryCache to Redis, from HybridCache to HTTP caching and output caching, from invalidation to stampede and measurement. .NET 11 brings the three missing pieces: measurement comes out of the box, duration can be chosen from the value, and output cache policies can be resolved at runtime. The question underneath all of them is the same as in the first post: when this data changes, who removes it, and who can see the old version for how long? We'll cover Redis beyond caching in the upcoming Database › Redis series, and the CDN layer in the upcoming Domain, DNS (Domain Name System) and CDN series.