A cache looks like the easiest way to speed up a slow query: put the result somewhere, and if it is asked for again, return it from there. The catch is that cache code is easy to write and hard to get right. Screens showing stale data, a database hammered by thousands of simultaneous requests, and "it works after clearing the cache" bugs usually come from these details.
In this post I approach caching in .NET through three questions: What should I store, and for how long? What happens when the data changes? What happens when many requests hit the same missing key at once? Then I look at HybridCache, which solves much of this out of the box.
First: do you actually need a cache
A cache is an optimization, and like every optimization it should start with measurement. Confirm that the query is really slow, that it is called often, and that a briefly outdated result is acceptable. Sometimes a missing index or an unnecessary N+1 query is the real source of the problem the cache would hide; fixing it adds far less complexity than a cache does.
Good candidates are rarely changing reference data, expensive summaries that are the same for everyone, and responses from rate-limited external services. Bad candidates are user-specific, fast-changing, or costly-to-get-wrong data such as balances or stock counts.
The cache-aside pattern
The most common pattern is cache-aside: look in the cache first; if it is missing, read from the source and write the result to the cache. With IMemoryCache it looks like this:
public async Task<ProductDto?> GetProductAsync(int id, CancellationToken ct)
{
var key = $"product:{id}";
if (cache.TryGetValue(key, out ProductDto? cached))
return cached;
var product = await db.Products
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstOrDefaultAsync(ct);
if (product is not null)
cache.Set(key, product, TimeSpan.FromMinutes(5));
return product;
}
This works, but it has two silent problems. First, records that are not found are never cached, so every request for a nonexistent id goes to the database. Second, concurrent requests that arrive while the key is missing all go to the database together. That second one is called a stampede.
Key and expiration design
Build the key from every input that determines the result. If language, tenant, page number or a filter changes the result, it must be part of the key. Otherwise one user's data may be shown to another; in multi-tenant systems this is a direct security bug. Centralizing key construction in a single helper instead of scattered string concatenation reduces mistakes.
There is no single right expiration, but two principles help. Choose it based on how stale the data may be, not on how fast you want things to be. Also, adding a small random jitter to the duration smooths out waves of simultaneous refreshes so that keys do not all expire at the same moment.
Invalidation: the hard part
How you update the cache when data changes matters more than how long entries live. There are three approaches:
- Relying on expiration alone: The simplest. Data can be stale for at most the duration; if that is acceptable for the product, it is often enough.
- Deleting on write: The code that updates the data removes the related key from the cache. The next read loads fresh data. Make sure the code that writes the key and the code that deletes it live close together.
- Bulk deletion by tag: When you want to invalidate all product lists tied to a category in one step, you use a shared tag instead of knowing every key.
In every case, always keep the expiration as a safety net, so that a bug in invalidation only results in "slightly stale data".
Stampede: simultaneous missing keys
When a popular key expires, hundreds of requests in the same millisecond miss the cache and all run the same expensive query. The database becomes the most loaded point at that moment; the slowing query makes more requests wait, and the system can enter a self-reinforcing loop.
The core of the solution is that only one request computes the value for a given key while the others wait for its result. You can write this by hand (one SemaphoreSlim per key), but details such as cleaning up the lock dictionary, cancellation and failure handling are easy to get wrong. That is why using a ready-made solution makes sense.
A simple solution with HybridCache
HybridCache, from the Microsoft.Extensions.Caching.Hybrid package in .NET, combines an in-memory (L1) cache and a distributed (L2) cache under a single API, and collapses concurrent calls for the same key into one computation. Registration is simple:
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(1)
};
});
Usage reduces the whole cache-aside code to a single call:
public async Task<ProductDto?> GetProductAsync(int id, CancellationToken ct)
{
return await cache.GetOrCreateAsync(
$"product:{id}",
async token => await db.Products
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstOrDefaultAsync(token),
tags: ["products"],
cancellationToken: ct);
}
For simultaneous calls with the same key, the factory function runs only once. After an update you can invalidate a single entry with RemoveAsync($"product:{id}"), or all tagged entries with RemoveByTagAsync("products"). If an IDistributedCache implementation (for example Redis) is registered, HybridCache uses it automatically for the distributed layer; if not, only the local cache works.
In a distributed cache, values are serialized, so the types you cache should be simple, serializable DTOs. Storing EF Core entities directly causes problems because of related objects and change-tracking information.
Observing the cache
You cannot know whether a cache helps if you do not measure it. Publish the hit and miss rates, the duration of the factory function, and cache-related errors as metrics. If the hit rate is low, either the key design is wrong or the duration is too short. If the hit rate is high but users complain about stale data, there is a gap in the invalidation flow.
In tests, instead of replacing the cache with a fake dependency, verify the behavior in both the filled and empty states: the second call should not run the factory, and a call after an update should see the new data.
Closing
A cache trades complexity for speed; for the trade to be worth it, you need a measured need and explicit rules. First improve the query itself, decide the acceptable staleness for the cached data together with the product side, build the key from every input that affects the result, and back invalidation with expiration. Instead of trying to solve stampedes by hand, use a proven building block like HybridCache. Finally, because the cache is an invisible but behavior-defining part of the application, measure and test it like any other part.