Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETHybridCacheCachingRedisPerformance
Share:𝕏

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 API

Production issue I've seen: A system used IMemoryCache per-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

XML
<!-- Api.csproj or Infrastructure.csproj -->
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.*" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.*" />

Registration

C#
// 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

C#
// 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

C#
// 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 100

This is handled automatically by HybridCache — no extra code required.


Tag-Based Invalidation Strategy

C#
// 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.

C#
// 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 IMemoryCache and IDistributedCache with 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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.