HybridCache
HybridCache, added in .NET 9, combines a local and a distributed cache and adds stampede protection and tag invalidation.
ValueTask<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory, HybridCacheEntryOptions? options = null, IEnumerable<string>? tags = null, CancellationToken cancellationToken = default)The two caches you have met solve different halves of one problem. A cache inside the process is fast but private to one replica, meaning one running copy of the app. A distributed cache such as Redis is shared by every replica but costs a network round trip on every read.
HybridCache uses both. It looks in local memory first, then in the shared store, and only then calls your code to produce the value. The result is written back to both levels. It arrived with .NET 9, in the Microsoft.Extensions.Caching.Hybrid package, and it also runs on older runtimes.
One method does most of the work
GetOrCreateAsync takes a key and a factory: the function that produces the value when the cache has not got it. Register the service with builder.Services.AddHybridCache(). If an IDistributedCache is registered, that becomes the second level automatically; if not, HybridCache is local only and still worth using. Expiration sets the lifetime in both levels, and LocalCacheExpiration can keep the in-process copy for a shorter time.
Stampede protection and tags
If fifty requests miss the same key at the same moment, one HybridCache instance runs the factory once and the other forty-nine wait for that result. Entries can also carry tags, which are plain strings naming a group, and RemoveByTagAsync invalidates a whole group in one call.
What to watch
Values reach the shared store as JSON by default, so a cached type must serialise. An entry above MaximumPayloadBytes, which is 1 MB by default, is logged and not cached. Tag invalidation is a rule rather than a delete: the stored bytes stay until they expire naturally.
Example
using Microsoft.Extensions.Caching.Hybrid;
// Package: Microsoft.Extensions.Caching.Hybrid
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// With no IDistributedCache registered this is a local cache that still has
// stampede protection. Register Redis as well and it becomes two-level.
builder.Services.AddHybridCache();
WebApplication app = builder.Build();
HybridCacheEntryOptions entryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5), // both levels
LocalCacheExpiration = TimeSpan.FromMinutes(1) // the in-process copy only
};
app.MapGet("/products/{id:int}", async (int id, HybridCache cache, CancellationToken token) =>
{
Product product = await cache.GetOrCreateAsync(
$"product:{id}", // key
async cancel => await LoadProductAsync(id, cancel), // factory
entryOptions,
["products"], // tags
token);
return Results.Ok(product);
});
// Invalidates every entry tagged "products".
app.MapPost("/products/purge", async (HybridCache cache, CancellationToken token) =>
{
await cache.RemoveByTagAsync("products", token);
return Results.NoContent();
});
app.Run();
static async ValueTask<Product> LoadProductAsync(int id, CancellationToken token)
{
await Task.Delay(50, token); // stands in for a database read
return new Product(id, "Blue mug");
}
record Product(int Id, string Name);When to use it
- Make a product page fast on a replica that has just started, by reading the shared copy instead of the database.
- Stop one expired popular key from firing fifty identical queries at Postgres.
- Clear every cached entry for one tenant after a bulk import, using a tag.
- Keep a short local copy of data that is expensive to fetch but tolerable a minute out of date.
More examples
Redis as the second level, with global defaults
This is the production registration: Redis for the shared level, one set of defaults in AddHybridCache so call sites need not repeat them, and the two limits stated out loud. An entry over MaximumPayloadBytes is logged and silently not cached, so set it deliberately rather than discovering it. By default HybridCache deserialises a fresh copy per caller to avoid two requests sharing one mutable object; sealed plus the attribute is how you opt out of that copying when the type genuinely cannot change.
using System.ComponentModel;
using Microsoft.Extensions.Caching.Hybrid;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// HybridCache uses whatever IDistributedCache is registered as its second level.
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
builder.Services.AddHybridCache(options =>
{
options.MaximumPayloadBytes = 1024 * 1024; // 1 MB, which is the default
options.MaximumKeyLength = 1024; // characters, also the default
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(2)
};
});
WebApplication app = builder.Build();
app.Run();
// sealed plus [ImmutableObject(true)] tells HybridCache that the value cannot
// change, so one deserialised instance can be handed to several callers.
[ImmutableObject(true)]
public sealed record CachedProduct(int Id, string Name);The state overload, with no captured variables
GetOrCreateAsync has a second form that takes a state value and passes it to the factory. It exists to avoid the small object the compiler creates when a lambda captures variables from around it. Reach for it only on a path you have measured: the plain overload is shorter and the difference is invisible in most apps. Passing null for the options parameter means the defaults from AddHybridCache apply.
using Microsoft.Extensions.Caching.Hybrid;
public sealed class ProductReader(HybridCache cache, ProductRepository repository)
{
public ValueTask<Product> GetAsync(int id, CancellationToken token = default)
{
// The tuple is the state. Because the lambda is static it captures
// nothing, so no closure object is allocated per call.
return cache.GetOrCreateAsync(
$"product:{id}",
(id, repository),
static async (state, cancel) => await state.repository.LoadAsync(state.id, cancel),
null,
["products"],
token);
}
}
public sealed class ProductRepository
{
public async ValueTask<Product> LoadAsync(int id, CancellationToken token)
{
await Task.Delay(50, token);
return new Product(id, "Blue mug");
}
}
public sealed record Product(int Id, string Name);Seeing stampede protection on one key
The same fifty callers against IMemoryCache could run the factory fifty times. HybridCache lets one through and gives every other caller that single result, so the count stays at one. Interlocked.Increment is used because the counter would otherwise be written from several threads at once. This is a demonstration rather than production shape: in a real app the cache arrives through dependency injection, not from a ServiceCollection you build by hand.
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;
ServiceCollection services = new ServiceCollection();
services.AddHybridCache();
HybridCache cache = services.BuildServiceProvider().GetRequiredService<HybridCache>();
int factoryRuns = 0;
async ValueTask<string> LoadAsync(CancellationToken token)
{
Interlocked.Increment(ref factoryRuns);
await Task.Delay(100, token); // slow enough for the others to pile up
return "loaded";
}
// Fifty callers want the same missing key at the same moment.
Task<string>[] callers = new Task<string>[50];
for (int i = 0; i < callers.Length; i++)
{
callers[i] = cache.GetOrCreateAsync("report", LoadAsync).AsTask();
}
string[] results = await Task.WhenAll(callers);
Console.WriteLine(results[0]);
Console.WriteLine($"factory runs: {factoryRuns}");
Discussion