Microsoft HybridCache — L1 In-Memory Plus L2 Redis in One API
How HybridCache works in .NET 9+: the two-layer architecture, stampede protection, tag-based invalidation, and the production problems it solves compared to IMemoryCache and IDistributedCache separately.
Why HybridCache
Before HybridCache (.NET 9), you had to choose:
IMemoryCache: fast (in-process), not shared across instances, no stampede protection
IDistributedCache: shared (Redis), serializes everything, slower, stampede-prone
HybridCache: ✓ L1 in-memory (fast)
✓ L2 distributed/Redis (shared across all instances)
✓ Stampede protection (only one request populates the cache)
✓ Tag-based invalidation (invalidate by tag, not just key)
✓ Single unified APIProduction issue I've seen: A system used
IMemoryCacheper-instance for frequently read patient medication lists. When they scaled to 4 instances, each instance had its own cache. A cache invalidation on instance 1 (after a prescription update) was invisible to instances 2, 3, and 4. For the next 5 minutes, 75% of requests served stale data. HybridCache's L2 layer with tag-based invalidation fixes this — invalidating a tag propagates to all instances via Redis.
Installation
<!-- Api.csproj or Infrastructure.csproj -->
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.*" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.*" />Registration
// Infrastructure/DependencyInjection.cs
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// L2: Redis distributed cache
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = configuration.GetConnectionString("Redis");
});
// HybridCache (L1 + L2 combined)
services.AddHybridCache(options =>
{
options.MaximumPayloadBytes = 1024 * 1024; // 1 MB max payload
options.MaximumKeyLength = 512;
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5), // L2 TTL
LocalCacheExpiration = TimeSpan.FromSeconds(30), // L1 TTL (shorter)
};
});
return services;
}Using HybridCache in a Query Handler
// Application/Patients/Queries/GetPatient/GetPatientQueryHandler.cs
using Microsoft.Extensions.Caching.Hybrid;
public sealed class GetPatientQueryHandler
{
private readonly IPatientRepository _patients;
private readonly HybridCache _cache;
public GetPatientQueryHandler(
IPatientRepository patients,
HybridCache cache)
{
_patients = patients;
_cache = cache;
}
public async Task<Result<PatientResponse>> Handle(
GetPatientQuery query, CancellationToken ct)
{
var cacheKey = $"patient:{query.PatientId.Value}";
var response = await _cache.GetOrCreateAsync(
key: cacheKey,
factory: async cacheEntry =>
{
var patient = await _patients.GetByIdAsync(query.PatientId, cacheEntry);
if (patient is null) return null;
return MapToResponse(patient);
},
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(1),
},
tags: [$"patient-{query.PatientId.Value}"], // for invalidation
ct);
if (response is null)
return Result.Failure<PatientResponse>(PatientErrors.NotFound);
return Result.Success(response);
}
}Cache Invalidation After a Write
// Application/Patients/Commands/AddPrescription/AddPrescriptionCommandHandler.cs
public sealed class AddPrescriptionCommandHandler
{
private readonly IPatientRepository _patients;
private readonly IUnitOfWork _unitOfWork;
private readonly HybridCache _cache;
public async Task<Result<PrescriptionId>> Handle(
AddPrescriptionCommand command, CancellationToken ct)
{
var patient = await _patients.GetByIdAsync(command.PatientId, ct);
if (patient is null)
return Result.Failure<PrescriptionId>(PatientErrors.NotFound);
var dosageResult = Dosage.Create(command.DosageAmount, command.DosageUnit);
if (dosageResult.IsFailure)
return Result.Failure<PrescriptionId>(dosageResult.Error);
var codeResult = MedicationCode.Create(command.MedicationCode);
if (codeResult.IsFailure)
return Result.Failure<PrescriptionId>(codeResult.Error);
var prescription = Prescription.Create(codeResult.Value, dosageResult.Value, command.Frequency);
var addResult = patient.AddPrescription(prescription.Value);
if (addResult.IsFailure)
return Result.Failure<PrescriptionId>(addResult.Error);
await _unitOfWork.SaveChangesAsync(ct);
// Invalidate the patient cache — the prescription list has changed
await _cache.RemoveByTagAsync($"patient-{command.PatientId.Value}", ct);
return Result.Success(prescription.Value.Id);
}
}Stampede Protection
Without stampede protection, when a cache entry expires under high load, all concurrent requests hit the database simultaneously:
Without HybridCache:
T=0: Cache entry expires
T=1: 100 concurrent requests all miss cache
T=1: 100 concurrent requests all hit database
T=2: Database CPU spikes to 100%
With HybridCache:
T=0: Cache entry expires
T=1: 100 concurrent requests all "miss" locally
T=1: HybridCache serializes: 1 request calls the factory, 99 wait
T=2: 1 request populates cache, 99 requests receive the cached value
T=2: Database receives 1 query, not 100This is handled automatically by HybridCache — no extra code required.
Tag-Based Invalidation Strategy
// Naming convention for tags:
// "patient-{patientId}" → invalidate one patient's data
// "patient-list" → invalidate all list queries
// "prescriptions-{patientId}" → invalidate prescriptions for one patient
// "drug-orders" → invalidate all drug orders
// When you update a patient's name:
await _cache.RemoveByTagAsync($"patient-{patientId.Value}", ct);
// When you add/remove a patient (affects lists):
await _cache.RemoveByTagAsync("patient-list", ct);
// When you add a prescription:
await _cache.RemoveByTagAsync($"patient-{patientId.Value}", ct);
await _cache.RemoveByTagAsync($"prescriptions-{patientId.Value}", ct);PRO TIP — Don't Cache Everything
Cache the data that is expensive to compute and read frequently. Patient profile data, medication lists, and drug formulary entries are good candidates. Command results, newly created entities, and personalized responses are not. Over-caching wastes memory and creates consistency bugs that are hard to trace.
// Good candidates for caching:
// - Frequently-read, rarely-changed reference data (drug formulary, ICD-10 codes)
// - Expensive aggregation queries (patient risk scores, summary statistics)
// - External API responses (drug interaction database lookups)
// Poor candidates:
// - Command results (just-created entities, not read-heavy)
// - User-specific sessions (personalized, not shared)
// - Real-time data (INR readings, current vitals)Key Takeaway
HybridCache replaces both
IMemoryCacheandIDistributedCachewith a single coherent API. The L1 layer (in-memory) serves hot data without a network hop. The L2 layer (Redis) shares cache state across all application instances. Stampede protection means cache expiry under high load costs one database query, not one per concurrent request. Tag-based invalidation means you never have to track every possible cache key that references a piece of data — you tag it once and invalidate by tag.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.