Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
HybridCacheCaching.NET 9RedisASP.NET Core
Share:𝕏

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[] handling

Setup

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

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

C#
// 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);
C#
// 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 both

Comparing the Cache APIs

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

Serialization Configuration

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

Production 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.

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.