Caching helps applications respond faster by keeping frequently requested data in memory or in a distributed cache such as Redis. Instead of querying a database or calling an external API for every request, the application can reuse a previously calculated result.
However, caching introduces a problem that often appears under heavy traffic. When a popular cache entry expires, many requests may attempt to rebuild the same value at nearly the same time. Each request discovers that the value is missing and independently queries the database or calls the downstream service.
This situation is known as a cache stampede.
A cache stampede can overload a database, increase API response times, and cause cascading failures across services. Even an application with a high cache hit rate can experience this problem when a frequently accessed key expires.
In this article, we will explore why cache stampedes happen and how to prevent them in C# applications using IMemoryCache, HybridCache, and distributed coordination techniques.
What Causes a Cache Stampede?
Consider an API that returns product information. The application stores the result in a cache for five minutes.
During those five minutes, thousands of requests can retrieve the cached value without querying the database. When the entry expires, the next requests find a cache miss.
If 200 requests arrive at approximately the same time, all 200 may query the database before any request finishes rebuilding the cache entry.
The sequence looks like this:
Popular cache entry expires
Product information is no longer available in the cache.
Multiple requests arrive
Each request observes the same cache miss.
Multiple database queries execute
Query 1
Query 2
Query 3 ... N
Database receives unnecessary load
Identical work runs repeatedly instead of once.
The problem is not simply that the cache entry expired. The problem is that the application allows multiple requests to perform the same expensive work concurrently.
If the database slows down under this load, requests take longer to finish. More requests remain in flight, which can increase resource consumption and make the outage worse.
Why a Normal Cache Lookup Is Not Enough
A common implementation uses IMemoryCache to cache the result of an expensive database query.
public async Task<Product?> GetProductAsync(
int productId,
CancellationToken cancellationToken)
{
string key = $"product:{productId}";
if (_cache.TryGetValue(key, out Product? product))
{
return product;
}
product = await _dbContext.Products
.AsNoTracking()
.SingleOrDefaultAsync(
p => p.Id == productId,
cancellationToken);
if (product is not null)
{
_cache.Set(
key,
product,
TimeSpan.FromMinutes(5));
}
return product;
}This implementation is reasonable for basic caching, but it has a race condition.
Imagine that three requests execute TryGetValue before the first database query completes. All three receive a cache miss. Each request starts its own query, and all three eventually write the same key.
The cache prevents repeated work after the value is populated, but it does not prevent duplicate work while the value is being created.
A safer design needs to coordinate cache population, not just cache access.
Use Single-Flight Loading with a Semaphore
One approach is to allow only one request at a time to rebuild a particular cache entry within the same application process.
The first request acquires a semaphore and loads the data. Other requests wait. Once the first request populates the cache, waiting requests check the cache again and reuse the result.
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using System.Collections.Concurrent;
public sealed class ProductCacheService
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _dbContext;
private static readonly ConcurrentDictionary<int, SemaphoreSlim>
Locks = new();
public ProductCacheService(
IMemoryCache cache,
AppDbContext dbContext)
{
_cache = cache;
_dbContext = dbContext;
}
public async Task<Product?> GetProductAsync(
int productId,
CancellationToken cancellationToken)
{
string key = $"product:{productId}";
if (_cache.TryGetValue(key, out Product? cached))
{
return cached;
}
SemaphoreSlim semaphore = Locks.GetOrAdd(
productId,
_ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(cancellationToken);
try
{
// Another request may have populated the cache
// while this request was waiting.
if (_cache.TryGetValue(key, out cached))
{
return cached;
}
Product? product = await _dbContext.Products
.AsNoTracking()
.SingleOrDefaultAsync(
p => p.Id == productId,
cancellationToken);
if (product is not null)
{
_cache.Set(
key,
product,
TimeSpan.FromMinutes(5));
}
return product;
}
finally
{
semaphore.Release();
}
}
}The second cache check is essential. Without it, each waiting request would query the database after acquiring the semaphore, even though the previous request had already populated the cache.
This example illustrates the single-flight pattern: multiple callers request the same value, but only one performs the expensive load.
There are two important production considerations.
First, the static dictionary retains semaphores for every distinct product ID accessed. If the key space grows without bound, this can cause a memory leak. Use a bounded lock registry, a well-tested keyed-lock implementation, or a cache-loading abstraction that handles per-key coordination safely.
Second, this solution coordinates requests only within one process. If an application runs on ten servers, each server can still initiate one database query for the same missing key. A distributed application needs a cross-instance strategy when local coordination is not sufficient.
Use HybridCache for Cache Stampede Protection
For modern .NET applications, HybridCache is another option worth considering. It provides a unified caching API that can use in-process caching and an optional distributed cache, and it includes protection against multiple concurrent callers performing the same cache fill within the applicable coordination scope.
Add the package if your application does not already reference it:
dotnet add package Microsoft.Extensions.Caching.HybridRegister the service:
using Microsoft.Extensions.Caching.Hybrid;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHybridCache();
var app = builder.Build();You can then use the cache-aside pattern through GetOrCreateAsync:
public sealed class ProductService
{
private readonly HybridCache _cache;
private readonly AppDbContext _dbContext;
public ProductService(
HybridCache cache,
AppDbContext dbContext)
{
_cache = cache;
_dbContext = dbContext;
}
public ValueTask<Product?> GetProductAsync(
int productId,
CancellationToken cancellationToken)
{
return _cache.GetOrCreateAsync(
$"product:{productId}",
async token =>
{
return await _dbContext.Products
.AsNoTracking()
.SingleOrDefaultAsync(
p => p.Id == productId,
token);
},
cancellationToken: cancellationToken);
}
}The factory performs the database query only when the cache needs a value. Concurrent callers for the same key can share the result rather than independently running the factory.
Join the conversation! Your thoughts help the community grow.