MemoryCache finally gets proper visibility in .NET 11

Some .NET features are exciting because they change how you write code. Others are useful because they help you understand the code you already have in production. The new MemoryCache OpenTelemetry metrics in .NET 11 fall into the second camp, and that’s why I like them. In .NET 11, MemoryCache can emit built-in OpenTelemetry compatible metrics. You opt in by setting TrackStatistics = true, and the cache starts publishing useful signals such as requests, hits, misses, evictions, entries, and estimated size.
That sounds small. In real systems, it could be very useful.
Most cache problems are invisible until production
In-memory caching is easy to add. You fetch something expensive, store it for a while, and avoid doing the same work again. That could be a lookup table, a schema file, a feature flag, a config document, a permissions set, a compiled rule, or a small reference data response from another service. The code usually looks reasonable. The API still works. The cache appears to be doing its job. Then production tells a different story.
Maybe the cache is missing far more often than expected. Maybe entries are being evicted too aggressively. Maybe the cache is growing because size limits weren’t set properly. Maybe a short TTL seemed sensible locally but causes unnecessary pressure when real traffic arrives. Maybe every instance has its own cold cache, and scale-out makes the downstream service work harder instead of less. Without metrics, you’re mostly guessing.
Why this is useful
The useful part of this feature isn’t that MemoryCache has statistics. The useful part is that those statistics can flow through the same observability pipeline as the rest of your application. If your application already uses OpenTelemetry, this means cache behaviour can sit beside request duration, dependency calls, exceptions, queue processing, database timings, and custom business metrics. That changes the conversation.
Instead of saying we think the cache is helping, you can look at the hit rate. Instead of saying the downstream API seems busy, you can check whether cache misses increased at the same time. Instead of guessing whether your cache size is sensible, you can watch entries and estimated size over time. That’s the difference between adding a cache and operating one.
What the code looks like
At the simple end, enabling cache statistics is just an option.
using Microsoft.Extensions.Caching.Memory;
var cache = new MemoryCache(new MemoryCacheOptions
{
TrackStatistics = true
});
In a normal application, you’d usually configure this through dependency injection.
builder.Services.AddMemoryCache(options =>
{
options.TrackStatistics = true;
});
Once enabled, .NET can publish cache metrics from the Microsoft.Extensions.Caching.Memory.MemoryCache meter. The important metrics are the practical ones. dotnet.cache.requests tells you how often the cache is being used. It includes a request type tag, so you can separate hits from misses. dotnet.cache.evictions tells you when entries are being pushed out. dotnet.cache.entries tells you how many entries are currently in the cache. dotnet.cache.estimated_size gives you a view of cache size, assuming your application uses cache sizes consistently. None of that is complicated, but it’s exactly the sort of data you need when a cache becomes part of the production path.
A real example
Imagine an API that serves form schemas. Each schema is stored as JSON. The API loads the schema, validates it, maybe resolves some lookup definitions, and then serves it to a workflow. The schema doesn’t change every second, so caching it for a short period makes sense.
The first version might be simple.
public sealed class FormSchemaService(IMemoryCache cache, IBlobSchemaStore store)
{
public async Task<FormSchema> GetSchemaAsync(string schemaName, CancellationToken stopToken)
{
return await cache.GetOrCreateAsync(
$"form-schema:{schemaName}",
async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
return await store.LoadAsync(schemaName, stopToken);
}) ?? throw new InvalidOperationException($"Schema '{schemaName}' could not be loaded.");
}
}
That code is fine as a starting point. The problem is what you don’t know. Is the one-hour TTL right? Are schemas being requested repeatedly, or are most requests for different schema names? Are deployments causing a burst of cold cache misses? Are you accidentally using slightly different cache keys for the same logical schema? Are evictions happening because the cache is under pressure? This is where the new metrics help.
A high cache hit rate tells you the cache is doing useful work. A low hit rate tells you the cache may be adding complexity without much benefit. A sudden increase in misses after a release might point to a key generation change. A steady rise in entries might suggest unbounded key growth. That’s useful engineering feedback.
This also helps with downstream reliability
Caches are often introduced for performance, but they also affect resilience. If your app depends on a database, blob store, document service, lookup API, or feature flag service, cache misses can turn into dependency calls. When those misses spike, the downstream dependency gets hit harder. Good cache metrics give you earlier warning.
If response times increase and cache misses increase at the same time, that’s a strong clue. If dependency calls increase but cache traffic stays stable, the issue may be elsewhere. If evictions jump before latency rises, your cache may be too small or your entries may be too large. That’s the kind of production clue you want during an incident.
The dangerous part
There is still a trap here. Metrics don’t make caching automatically correct. You still need sensible keys. You still need an expiry strategy. You still need to think about invalidation. You still need to be careful with large values. You still need to understand whether in-memory cache is the right choice when an app runs across multiple instances.
A local MemoryCache is per-process. If you scale from one instance to ten, each instance has its own cache. That can be perfectly fine for small reference data or short-lived computed values. It can be the wrong shape for shared state, strict consistency, or expensive warm up behaviour. The new metrics won’t decide that for you. They will make the trade off visible.
Where I’d use this
I’d use this anywhere MemoryCache sits on a meaningful production path. Reference data is the obvious case. Lookup values, country lists, product mappings, tenant settings, document schemas, and workflow configuration are all good candidates. It’s also useful for expensive computed data. For example, compiled rules, parsed templates, resolved permissions, or anything that takes enough work to justify caching but still needs to stay inside the application process.
I’d also use it when reviewing whether a cache should exist at all. That sounds backwards, but it’s often the most valuable use. Developers add caches because they seem sensible. Metrics tell you whether they’re earning their keep.
What I like about it
I like this feature because it makes a common production pattern easier to operate. Caching is one of those things teams add quickly and revisit slowly. It starts as a small optimisation, then becomes part of how the system behaves under load. Once that happens, you need visibility. The best thing about this .NET 11 change is that it doesn’t ask teams to invent their own cache instrumentation. It gives them a built in path that fits with OpenTelemetry. That’s a good direction for .NET.
More of the framework should work like this. The runtime and libraries already know a lot about what your application is doing. When that information is exposed as useful telemetry, teams can spend less time guessing and more time fixing the right thing.





