HybridCache — The Best of Both Caches in .NET 9
Microsoft.Extensions.Caching.Hybrid combines L1 in-process and L2 distributed caching with built-in stampede protection, tag-based invalidation, and a simpler API than managing IMemoryCache and IDistributedCache separately.
Why HybridCache
Managing two caches (IMemoryCache + IDistributedCache) manually requires boilerplate: check L1, miss, check L2, miss, load from DB, populate L2, populate L1, handle concurrent misses. HybridCache does all of this automatically.
IMemoryCache alone:
Fast but per-instance — not shared, no cross-instance consistency
IDistributedCache alone:
Shared but slower — serialization + network on every hit
HybridCache:
L1: IMemoryCache (fast, in-process)
L2: IDistributedCache/Redis (shared, persistent)
Stampede protection: only one request populates cache on miss
Tag-based invalidation: invalidate related entries by tag
Auto-serialization: no manual byte[] handlingSetup
// NuGet: Microsoft.Extensions.Caching.Hybrid (included in .NET 9 preview)
builder.Services.AddHybridCache(options =>
{
options.MaximumPayloadBytes = 1024 * 1024; // 1 MB max per entry
options.MaximumKeyLength = 512;
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(30), // L2 TTL
LocalCacheExpiration = TimeSpan.FromMinutes(5), // L1 TTL (shorter)
};
});
// Also register Redis for L2 (HybridCache auto-uses it)
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = builder.Configuration.GetConnectionString("Redis"));Basic GetOrCreateAsync
public sealed class DrugFormularyService
{
private readonly HybridCache _cache;
private readonly DrugRepository _repo;
public DrugFormularyService(HybridCache cache, DrugRepository repo)
=> (_cache, _repo) = (cache, repo);
public async ValueTask<List<DrugDto>> GetFormularyAsync(
Guid hospitalId, CancellationToken ct)
{
return await _cache.GetOrCreateAsync(
key: $"formulary:{hospitalId}",
factory: async (ct) =>
{
var drugs = await _repo.GetFormularyAsync(hospitalId, ct);
return drugs.Select(d => d.ToDto()).ToList();
},
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromHours(4),
LocalCacheExpiration = TimeSpan.FromMinutes(15),
Tags = [$"formulary", $"hospital:{hospitalId}"]
},
cancellationToken: ct);
}
}The factory runs exactly once on a cache miss — even if 100 concurrent requests miss at the same time. This is built-in stampede protection.
Tag-Based Invalidation
Tags group related cache entries so you can invalidate them together:
// Invalidate a single entry
await _cache.RemoveAsync($"formulary:{hospitalId}", ct);
// Invalidate all entries tagged with a hospital ID
// Useful when a hospital's data changes across multiple cache keys
await _cache.RemoveByTagAsync($"hospital:{hospitalId}", ct);
// Invalidate all formulary entries (across all hospitals)
await _cache.RemoveByTagAsync("formulary", ct);// Tagging example: patient data across multiple keys
await _cache.GetOrCreateAsync(
key: $"patient:{id}:summary",
factory: async ct => await LoadSummaryAsync(id, ct),
options: new HybridCacheEntryOptions
{
Tags = [$"patient:{id}", "patients"]
}, ct);
await _cache.GetOrCreateAsync(
key: $"patient:{id}:prescriptions",
factory: async ct => await LoadPrescriptionsAsync(id, ct),
options: new HybridCacheEntryOptions
{
Tags = [$"patient:{id}", "prescriptions"]
}, ct);
// When patient is updated, invalidate ALL their cached data at once
await _cache.RemoveByTagAsync($"patient:{patientId}", ct);
// Removes: patient:{id}:summary, patient:{id}:prescriptions, etc.L1 and L2 TTL Strategy
L1 (in-process, per instance):
Short TTL: 5-15 minutes
Fast but slightly stale — acceptable for most read scenarios
Lost on restart — L2 repopulates it automatically
L2 (Redis, shared):
Longer TTL: 30 minutes to 4 hours depending on data volatility
Survives restarts, shared across all instances
Serialization overhead (once per miss, not per request)
Result for hot data:
First request: factory runs, populates L2, populates L1
Next 5 minutes: all requests hit L1 (no network)
After 5 min: one request hits L2 (fast Redis), repopulates L1
After 4 hours: one request hits factory (DB), repopulates bothComparing the Cache APIs
// IMemoryCache — in-memory, no auto-serialize
var value = _memCache.GetOrCreate("key", e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
return LoadExpensive();
});
// IDistributedCache — manual bytes
var bytes = await _distCache.GetAsync("key", ct);
var value = bytes is null ? null : JsonSerializer.Deserialize<T>(bytes);
// ... populate, serialize, SetAsync
// HybridCache — two-level, automatic
var value = await _hybridCache.GetOrCreateAsync("key",
async ct => await LoadExpensive(ct), ct: ct);
// L1 check → L2 check → factory → populate both — all automaticSerialization Configuration
// HybridCache uses System.Text.Json by default
// Configure custom options
builder.Services.AddHybridCache()
.AddSerializerFactory(new SystemTextJsonSerializerFactory(
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
}));
// Or use a type-specific serializer
builder.Services.AddHybridCache()
.AddSerializer<PatientDto, CustomPatientSerializer>();Production Multi-Instance Scenario
4 API instances, load balancer distributing requests:
T=0: Patient record updated in DB
T=0: Instance 1 handler calls _cache.RemoveByTagAsync("patient:{id}")
T=0: L2 (Redis) entry removed — all instances share this
T=1s: Request hits Instance 2 for that patient
T=1s: L1 miss (cold) → L2 miss → factory runs → DB loaded → L2 + L1 populated
T=2s: Request hits Instance 2 again → L1 hit (fast)
T=2s: Request hits Instance 3 → L1 miss → L2 hit → L1 populated
Without tags: Instance 2 and 3 would serve stale data until TTL expires
With tags: InvalidateAsync("patient:{id}") clears L2 → all instances miss → fresh dataProduction issue I've seen: A team added a prescription allergy alert system. A patient's allergy was updated, but 3 of 4 API instances continued serving the stale patient record (without the allergy) for 30 minutes because their in-memory cache had not expired. HybridCache tag invalidation would have cleared the entry across all instances immediately.
Key Takeaway
HybridCache is the recommended cache abstraction for .NET 9+ multi-instance applications. It combines in-process speed (L1) with distributed consistency (L2), adds built-in stampede protection (factory runs once on concurrent miss), and tag-based invalidation (remove all entries for a patient with one call). Migration from IMemoryCache + IDistributedCache manual management is a net simplification.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.