HybridCache

Use HybridCache for a two-level cache with stampede protection, per-entry options and tag-based invalidation with RemoveByTagAsync.

Syntaxbuilder.Services.AddHybridCache(); var p = await cache.GetOrCreateAsync($"product:{id}", async token => await store.LoadAsync(id, token), tags: ["products"], cancellationToken: ct); await cache.RemoveByTagAsync("products", ct);

HybridCache (package Microsoft.Extensions.Caching.Hybrid, GA since .NET 9) combines an in-memory L1 in each pod with an optional distributed L2, behind one call: GetOrCreateAsync(key, factory). It replaces the hand-written pattern of checking IMemoryCache, then Redis, then the database, then writing both back, and it fixes the two things that pattern usually gets wrong: stampedes and serialisation.

How a read flows

  1. Look in L1 (the IMemoryCache from DI). A hit returns immediately.
  2. Otherwise look in L2, which is whatever IDistributedCache is registered, typically Redis. A hit is deserialised and written to L1.
  3. Otherwise run the factory, then write L2 and L1.

With no IDistributedCache registered it is an L1-only cache that still gives you stampede protection. Concurrent callers for the same key in the same process share one factory execution: a hundred requests for a cold product run one query, and all hundred await the result. The coalescing is per process; five pods can still run five queries, which is usually acceptable.

Options and defaults

SettingDefaultMeaning
Expiration5 minutesLifetime in L2 and the overall bound
LocalCacheExpirationsame as ExpirationLifetime in this pod's L1
MaximumPayloadBytes1 MBLarger values are returned but not cached
MaximumKeyLength1024Longer keys bypass the cache
Flagsnonee.g. DisableLocalCache, DisableDistributedCache

Set DefaultEntryOptions deliberately. The key decision is LocalCacheExpiration: L1 entries on other pods are not told about removals, so it is the staleness window after a write. Thirty seconds of L1 in front of ten minutes of Redis is a common split.

Tags and removal

RemoveAsync(key) removes one entry. GetOrCreateAsync and SetAsync accept tags, and RemoveByTagAsync("category:lighting") invalidates every entry carrying that tag. Tag removal is logical: the cache records that the tag was invalidated at a point in time and treats older entries with it as misses, so it costs the same for one entry or ten thousand. Plan tags around writes: if changing a product must refresh its detail and every list that contains it, tag the lists with the category and remove both tags on update.

Serialisation and object reuse

Strings and byte[] are stored as is; other types use System.Text.Json unless you register an IHybridCacheSerializer<T> with AddSerializer. Because L1 keeps serialised bytes, each hit deserialises a fresh object, so callers cannot corrupt each other's copy. A type that is sealed and marked [ImmutableObject(true)] is reused instead, which saves the allocation.

Failure modes

Factory exceptions are not cached; every caller waiting on that factory gets the exception. Oversized payloads and long keys fail quietly into misses, so log or meter them. Use the TState overload with a static lambda on hot paths to avoid a closure allocation per call.

Example

Example · csharp
using Microsoft.Extensions.Caching.Hybrid;

var builder = WebApplication.CreateBuilder(args);

// L2: any IDistributedCache. Without this line HybridCache is in-memory only.
builder.Services.AddStackExchangeRedisCache(o =>
{
    o.Configuration = builder.Configuration.GetConnectionString("Redis");
    o.InstanceName = "catalog-api:";
});

builder.Services.AddHybridCache(o =>
{
    o.MaximumPayloadBytes = 512 * 1024;
    o.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(10),          // Redis
        LocalCacheExpiration = TimeSpan.FromSeconds(30), // each pod; the cross-pod staleness window
    };
});
builder.Services.AddSingleton<ProductStore>();

var app = builder.Build();

app.MapGet("/products/{id:int}", async (int id, HybridCache cache, ProductStore store, CancellationToken ct) =>
{
    var product = await cache.GetOrCreateAsync(
        $"product:v1:{id}",
        (store, id),
        static (state, token) => state.store.FindAsync(state.id, token),
        tags: [$"product:{id}"],
        cancellationToken: ct);
    if (product is null)
    {
        // Do not keep a not-found for the 10-minute default: a product created later would 404.
        await cache.RemoveAsync($"product:v1:{id}", ct);
        return Results.NotFound();
    }
    return Results.Ok(product);
});

app.MapGet("/categories/{category}/products", async (string category, HybridCache cache, ProductStore store, CancellationToken ct) =>
{
    category = category.ToLowerInvariant();
    var products = await cache.GetOrCreateAsync(
        $"category-products:v1:{category}",
        (store, category),
        static (state, token) => state.store.ListByCategoryAsync(state.category, token),
        tags: [$"category:{category}"],
        cancellationToken: ct);
    return Results.Ok(products);
});

app.MapPut("/products/{id:int}/price", async (int id, PriceChange change, HybridCache cache, ProductStore store, CancellationToken ct) =>
{
    var product = await store.UpdatePriceAsync(id, change.Price, ct);
    if (product is null) return Results.NotFound();

    // One write invalidates the detail entry and every list that shows the product.
    await cache.RemoveByTagAsync([$"product:{id}", $"category:{product.Category}"], ct);
    return Results.NoContent();
});

app.Run();

public sealed record PriceChange(decimal Price);
public sealed record Product(int Id, string Name, string Category, decimal Price);

public sealed class ProductStore
{
    private readonly Dictionary<int, Product> _rows = new()
    {
        [1] = new(1, "Brass desk lamp", "lighting", 89.00m),
        [2] = new(2, "Linen lampshade", "lighting", 24.50m),
    };

    public async ValueTask<Product?> FindAsync(int id, CancellationToken ct)
    {
        await Task.Delay(40, ct);
        lock (_rows) return _rows.GetValueOrDefault(id);
    }

    public async ValueTask<List<Product>> ListByCategoryAsync(string category, CancellationToken ct)
    {
        await Task.Delay(60, ct);
        lock (_rows) return _rows.Values.Where(p => p.Category == category).OrderBy(p => p.Id).ToList();
    }

    public async Task<Product?> UpdatePriceAsync(int id, decimal price, CancellationToken ct)
    {
        await Task.Delay(20, ct);
        lock (_rows)
        {
            if (!_rows.TryGetValue(id, out var current)) return null;
            return _rows[id] = current with { Price = price };
        }
    }
}

When to use it

  • Serve product detail pages from a 30-second in-pod cache backed by a 10-minute Redis cache, with one database query per cold product across a burst of traffic.
  • Invalidate every cached list in the lighting category with one RemoveByTagAsync call when a merchandiser changes a price.
  • Replace a hand-written IMemoryCache plus IDistributedCache wrapper with one GetOrCreateAsync call and delete 200 lines of cache code.

More examples

A test proving the factory runs once under concurrency

Fifty concurrent calls for a cold key produce one factory call; the other forty-nine wait for the first and receive the same value. Run the same test against <code>IMemoryCache.GetOrCreateAsync</code> and <code>calls</code> comes out near fifty. The test needs no Redis: with no <code>IDistributedCache</code> registered, <code>AddHybridCache</code> runs L1 only, which is also a sensible setup for unit tests of code that takes a <code>HybridCache</code>.

Example · csharp
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

public sealed class HybridCacheStampedeTests
{
    [Fact]
    public async Task Concurrent_misses_share_one_factory_call()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddHybridCache();
        await using var provider = services.BuildServiceProvider();
        var cache = provider.GetRequiredService<HybridCache>();
        var ct = TestContext.Current.CancellationToken;

        var calls = 0;
        async ValueTask<decimal> LoadStockValue(CancellationToken token)
        {
            Interlocked.Increment(ref calls);
            await Task.Delay(100, token);
            return 18_250.75m;
        }

        var results = await Task.WhenAll(Enumerable.Range(0, 50)
            .Select(_ => cache.GetOrCreateAsync("inventory:value:warehouse-3", LoadStockValue, cancellationToken: ct).AsTask()));

        Assert.Equal(1, calls);
        Assert.All(results, r => Assert.Equal(18_250.75m, r));
    }
}

Per-entry flags for data that must not be stale per pod

Options are per entry, so one cache can hold data with very different consistency needs. <code>DisableLocalCache</code> keeps an entry out of L1, so every pod reads the same Redis value and a removal is seen everywhere at once. Marking the record <code>sealed</code> and <code>[ImmutableObject(true)]</code> tells HybridCache it is safe to reuse the deserialised instance instead of deserialising per hit.

Example · csharp
using System.ComponentModel;
using Microsoft.Extensions.Caching.Hybrid;

public sealed class InventoryReader(HybridCache cache, IStockService stock)
{
    // Stock levels change constantly: share them through Redis only, never from a pod-local copy.
    private static readonly HybridCacheEntryOptions StockOptions = new()
    {
        Expiration = TimeSpan.FromSeconds(15),
        Flags = HybridCacheEntryFlags.DisableLocalCache,
    };

    // Warehouse metadata rarely changes: long L1 is fine.
    private static readonly HybridCacheEntryOptions WarehouseOptions = new()
    {
        Expiration = TimeSpan.FromHours(1),
        LocalCacheExpiration = TimeSpan.FromMinutes(10),
    };

    public ValueTask<int> GetAvailableAsync(string sku, CancellationToken ct) =>
        cache.GetOrCreateAsync($"stock:v1:{sku}", (stock, sku),
            static (s, token) => s.stock.CountAvailableAsync(s.sku, token),
            StockOptions, cancellationToken: ct);

    public ValueTask<Warehouse> GetWarehouseAsync(string code, CancellationToken ct) =>
        cache.GetOrCreateAsync($"warehouse:v1:{code}", (stock, code),
            static (s, token) => s.stock.GetWarehouseAsync(s.code, token),
            WarehouseOptions, tags: ["warehouses"], cancellationToken: ct);
}

// sealed + ImmutableObject(true): HybridCache may hand the same instance to every caller.
[ImmutableObject(true)]
public sealed record Warehouse(string Code, string City, string TimeZone);

public interface IStockService
{
    ValueTask<int> CountAvailableAsync(string sku, CancellationToken ct);
    ValueTask<Warehouse> GetWarehouseAsync(string code, CancellationToken ct);
}

Write-through on update with SetAsync

<code>SetAsync</code> writes the new value to L1 and L2 so the next read is a hit with fresh data, which suits an entry that is read right after it is written, such as a profile page the user is looking at. Write the value the database returned, not the request body, so database defaults and triggers are reflected. Never save a cached object back to the database: it may be 30 minutes old, and writing the whole row would overwrite newer Name or Email changes, so update only the changed column. Two concurrent writers can still set in the opposite order to their commits, so keep a TTL and prefer <code>RemoveAsync</code> when writes race.

Example · csharp
using Microsoft.Extensions.Caching.Hybrid;

public sealed record CustomerProfile(int Id, string Name, string Email, string Tier);

public interface ICustomerRepository
{
    // UPDATE customers SET tier = @tier WHERE id = @id RETURNING *: only the changed column is written.
    Task<CustomerProfile> UpdateTierAsync(int customerId, string tier, CancellationToken ct);
}

public sealed class CustomerProfileService(HybridCache cache, ICustomerRepository repository)
{
    public async Task<CustomerProfile> UpdateTierAsync(int customerId, string tier, CancellationToken ct)
    {
        // The database is the source of truth: commit first, then publish the committed row.
        var saved = await repository.UpdateTierAsync(customerId, tier, ct);

        await cache.SetAsync($"customer-profile:v1:{saved.Id}", saved,
            new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(30), LocalCacheExpiration = TimeSpan.FromSeconds(30) },
            tags: [$"customer:{saved.Id}"], cancellationToken: ct);

        return saved;
    }
}

Discussion

  • Be the first to comment on this lesson.