The cache problem we've been solving manually
For years, production teams at scale have written wrapper classes around IDistributedCache to add a memory layer, prevent cache stampedes, and handle tag-based invalidation. HybridCache in .NET 9+ is the cache API we always wanted: it combines L1 memory + L2 distributed storage + stampede protection into a single, straightforward GetOrCreateAsync call. This article walks through HybridCache in .NET 9+ as a practical guide for developers, showing how it eliminates boilerplate and makes production caching patterns the default.
What HybridCache actually solves
Before HybridCache, a typical production setup required you to:
- Query an in-process memory cache first
- Fall back to distributed cache (Redis, Memcached) if missed
- Handle the thundering herd problem—multiple requests computing the same cache miss simultaneously
- Manually track cache tags for invalidation
- Write serialization and deserialization logic
HybridCache handles all of this. A single call manages both layers, protects against stampedes by ensuring only one factory call per key happens concurrently, and bakes in tag-based invalidation. For e-commerce platforms, SaaS backends, and trading systems, this cuts cache-related bugs and maintenance burden significantly.
One GetOrCreateAsync call covers both layers
The core API is simple—and that simplicity is the point.
public async Task<Product> GetProductAsync(int id, CancellationToken ct)
{
var product = await _cache.GetOrCreateAsync(
$"product:{id}",
async ct => await _db.Products.FindAsync([id], ct),
cancellationToken: ct
);
return product;
}
On the first call, HybridCache checks the L1 memory layer. On miss, it checks L2 (your distributed cache). On a complete miss, it executes your factory function once and distributes the result to both layers. Subsequent calls return instantly from memory.
Compare this to a hand-rolled approach:
// The old way: verbose, error-prone, no stampede protection
var key = $"product:{id}";
if (_memoryCache.TryGetValue(key, out Product product))
return product;
if (await _distributedCache.GetAsync(key) is byte[] bytes)
{
product = JsonSerializer.Deserialize<Product>(bytes);
_memoryCache.Set(key, product, TimeSpan.FromMinutes(5));
return product;
}
// Now compute and store everywhere
product = await _db.Products.FindAsync([id], ct);
var serialized = JsonSerializer.SerializeToUtf8Bytes(product);
_memoryCache.Set(key, product, TimeSpan.FromMinutes(5));
await _distributedCache.SetAsync(key, serialized, null, ct);
return product;
HybridCache removes all that plumbing. The API is declarative—you specify the key and the factory—and the framework handles the rest.
Stampede protection: one factory call per key
Cache stampedes happen when a popular key expires and ten thousand concurrent requests all hit the database. HybridCache prevents this by locking on the key internally. Only one factory invocation runs per key at a time; other callers await the result.
// Scenario: popular product page, high concurrency
// Request 1 triggers factory
var p1 = await _cache.GetOrCreateAsync(
"product:12345",
async ct => {
Console.WriteLine("Factory running");
return await _db.Products.FindAsync([12345], ct);
},
cancellationToken: ct
);
// Requests 2-100 block here internally, don't call factory
// All 100 get the same result without 100 database queries
var p2 = await _cache.GetOrCreateAsync(
"product:12345",
async ct => await _db.Products.FindAsync([12345], ct),
cancellationToken: ct
);
This protection is automatic. You don't configure it or pass extra flags. It's the right default for production systems where cache misses can be expensive.
Tag-based invalidation built in
After setting a value, you can assign tags. When you invalidate by tag, all associated entries across both L1 and L2 caches are removed. This is essential for keeping related data in sync—for example, invalidating all product caches when inventory updates.
// When caching product data, tag it
var cacheEntryOptions = new CacheEntryOptions
{
LocalCacheDuration = TimeSpan.FromMinutes(5),
DistributedCacheDuration = TimeSpan.FromHours(1),
};
var product = await _cache.GetOrCreateAsync(
$"product:{id}",
async ct => await _db.Products.FindAsync([id], ct),
cacheEntryOptions,
tags: ["products", $"category:{product.CategoryId}"],
cancellationToken: ct
);
// Later, when inventory is updated
await _cache.RemoveByTagAsync("products", ct);
// Or remove only products in a specific category
await _cache.RemoveByTagAsync($"category:5", ct);
Tags are stored alongside entries. This approach scales well for domain-driven cache invalidation without manual tag tracking or complex cache key patterns.
Replaces most hand-written IDistributedCache wrappers
Many teams maintain internal wrapper classes around IDistributedCache. HybridCache standardizes and improves on those patterns:
| Aspect | Hand-written wrapper | HybridCache |
|---|---|---|
| Dual-layer management | Manual code in wrapper | Built-in |
| Stampede protection | Requires SemaphoreSlim or similar | Built-in |
| Serialization | Manual or framework-dependent | Automatic (System.Text.Json by default) |
| Tag invalidation | Rarely implemented | First-class feature |
| Testing | Mock the wrapper | HybridCache mock available in test packages |
You can migrate existing wrapper code incrementally. Start by moving high-traffic, expensive queries to HybridCache, then expand coverage.
Real-world example: e-commerce product catalog
Let's walk through a realistic scenario: a ride-hailing or e-commerce platform caching product details and inventory.
public class ProductService
{
private readonly HybridCache _cache;
private readonly ProductDbContext _db;
public ProductService(HybridCache cache, ProductDbContext db)
{
_cache = cache;
_db = db;
}
public async Task<ProductDto> GetProductWithDetailsAsync(
int id,
CancellationToken ct)
{
var options = new CacheEntryOptions
{
LocalCacheDuration = TimeSpan.FromMinutes(10),
DistributedCacheDuration = TimeSpan.FromHours(2),
};
return await _cache.GetOrCreateAsync(
$"product:details:{id}",
async ct =>
{
var product = await _db.Products
.Include(p => p.Category)
.Include(p => p.Inventory)
.FirstOrDefaultAsync(p => p.Id == id, ct);
return new ProductDto
{
Id = product.Id,
Name = product.Name,
Price = product.Price,
CategoryName = product.Category.Name,
StockLevel = product.Inventory.Quantity,
};
},
options,
tags: ["products", $"category:{product.CategoryId}"],
cancellationToken: ct
);
}
public async Task UpdateProductAsync(Product product, CancellationToken ct)
{
await _db.SaveChangesAsync(ct);
// Invalidate the specific product and all products in its category
await _cache.RemoveByTagAsync($"product:details:{product.Id}", ct);
await _cache.RemoveByTagAsync($"category:{product.CategoryId}", ct);
}
}
Setup in your dependency injection:
services.AddHybridCache(options =>
{
options.MaximumLocalCacheSize = 256; // max entries in L1
options.MaximumDistributedCacheDuration = TimeSpan.FromHours(24);
});
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
Common mistakes
Mistake 1: Not setting appropriate durations. Set LocalCacheDuration shorter than DistributedCacheDuration. L1 is hot, L2 is shared across instances. If you set L1 to 1 hour, one instance will serve stale data while others refresh.
Mistake 2: Over-tagging. Tagging every entry with a generic tag like "all" defeats the purpose. Use specific, hierarchical tags: "category:5" rather than "products" alone if you're only invalidating a subset.
Mistake 3: Forgetting to handle null or missing data. If your factory returns null, HybridCache will cache it too. This is correct for "record doesn't exist" scenarios, but make sure your factory distinguishes between "not found" and "error."
Mistake 4: Ignoring the CancellationToken. Pass the token through your factory function. This allows graceful shutdown and timeout handling in production.
Key takeaways
- HybridCache provides a single unified API for L1 + L2 caching, eliminating boilerplate wrapper code that most production teams write anyway.
- Stampede protection is built-in and automatic—only one factory call per key, even under high concurrency.
- Tag-based invalidation replaces manual cache key tracking and makes keeping related data in sync straightforward.
- Appropriate duration settings are critical: keep L1 short, L2 longer, and respect instance boundaries.
- Migration from hand-rolled wrappers is incremental—start with high-value, expensive queries and expand.






Comments
Loading comments…