Redis is commonly used to reduce database load and improve application response times. Instead of executing the same database query for every request, an application retrieves frequently accessed data from Redis and returns the cached result.
This approach works well until a popular cache entry expires.
Imagine an application where thousands of users request the same product information. The product details are cached in Redis with a time-to-live (TTL) of 10 minutes. When the entry expires, multiple requests may arrive before the cache is refreshed.
Every request sees a cache miss and attempts to retrieve the same information from the database.
Instead of executing one database query, the application may execute hundreds or thousands of identical queries simultaneously.
This situation is known as a cache stampede, also called the thundering herd problem.
A cache stampede can increase database CPU usage, exhaust connection pools, increase API response times, and eventually make an application unavailable.
The important point is that having Redis in an application does not automatically prevent database overload. The application must also control how cache misses are handled.
This article explains how cache stampedes happen, how to identify them, and how to prevent them using practical Redis and C# implementation patterns.
What Is a Redis Cache Stampede?
A cache stampede occurs when multiple requests try to regenerate the same expired or missing cache entry at approximately the same time.
Normally, the caching process looks like this:
The application receives a request.
It checks Redis for the requested data.
If the data exists, Redis returns the cached value.
If the data does not exist, the application queries the database.
The application stores the result in Redis.
The application returns the result to the user.
This process works when requests arrive at different times.
However, when many requests arrive simultaneously after a popular key expires, they can all execute the database query before any request has populated the cache.
Example of a cache stampede
Consider an application that caches product information:
Cache Key: product:1001
TTL: 10 minutes
Database: PostgreSQL
Cache: RedisAssume 1,000 users request the same product at approximately the same time.
When the cache entry expires, the requests follow this pattern:
Request | Redis result | Application action |
|---|---|---|
Request 1 | Cache miss | Queries database |
Request 2 | Cache miss | Queries database |
Request 3 | Cache miss | Queries database |
Request 4 | Cache miss | Queries database |
Request 5 | Cache miss | Queries database |
... | ... | ... |
Request 1,000 | Cache miss | Queries database |
All these requests may execute the same database query:
SELECT
product_id,
product_name,
price,
stock_quantity
FROM products
WHERE product_id = 1001;The database performs unnecessary duplicate work because all requests need the same result.
If the database was already handling normal application traffic, this additional load can create a serious performance problem.
Why Cache Stampedes Happen
Several application and infrastructure conditions can contribute to cache stampedes.
Popular cache keys expire simultaneously
Some application data is requested much more frequently than other data.
Examples include:
Product details on an e-commerce website.
User profile information.
Dashboard statistics.
Application configuration.
Frequently accessed reports.
Trending content.
When a popular key expires, a large number of requests may attempt to regenerate it.
The problem becomes worse when many cache entries are assigned the same fixed TTL.
For example:
await database.StringSetAsync(
"product:1001",
productJson,
TimeSpan.FromMinutes(10)
);If several popular keys are populated around the same time with identical expiration durations, their expiration times can become synchronized.
This can create a burst of database queries.
Multiple application instances receive the same request
A distributed application commonly runs multiple application instances behind a load balancer.
For example:
Load Balancer
|
-----------------------
| | |
API 1 API 2 API 3
| | |
-----------------------
|
Redis
|
DatabaseSuppose all three application instances receive requests for the same expired cache key.
Each instance checks Redis and receives a cache miss.
Without coordination, all three instances may query the database.
A local lock inside one application instance cannot prevent another instance from executing the same query.
This is why distributed applications need a strategy that coordinates cache regeneration across instances.
Redis becomes unavailable
A cache stampede can also happen when Redis becomes temporarily unavailable.
If the application treats every Redis connection failure as an ordinary cache miss, all incoming requests may bypass Redis and query the database.
For example:
var cachedValue = await redis
.StringGetAsync(cacheKey);
if (cachedValue.IsNull)
{
return await LoadFromDatabaseAsync();
}This example does not distinguish between an actual missing key and a Redis connection failure.
In production, these conditions should be handled separately.
A Redis outage can affect the entire application because many requests may suddenly fall back to the database.
The application needs a fallback strategy that protects the database instead of sending unlimited requests to it.
Cache Stampede vs. Cache Avalanche vs. Cache Penetration
These terms describe different caching problems, although their symptoms can overlap.
Problem | What happens | Typical example |
|---|---|---|
Cache stampede | Many requests regenerate the same expired key | Thousands of requests query one product |
Cache avalanche | Many cache entries expire or become unavailable around the same time | A large group of keys expires together |
Cache penetration | Requests repeatedly ask for data that does not exist in the cache or database | Invalid product IDs generate repeated database queries |
Understanding the difference helps select the correct solution.
For example, request coalescing is useful for a cache stampede involving one key. TTL jitter helps distribute expiration times across many keys. Negative caching can reduce repeated database queries for nonexistent records.
These techniques solve related but different problems.
Preventing Cache Stampedes With Request Coalescing
Request coalescing ensures that only one request loads a particular missing value while other requests wait for the same result.
This pattern is also known as single-flight request handling.
Instead of allowing every request to query the database, the application coordinates requests using the cache key.
The process looks like this:
Multiple requests check Redis.
All requests receive a cache miss.
One request becomes responsible for loading the data.
Other requests wait for the same operation.
The responsible request queries the database.
The result is stored in Redis.
Waiting requests receive the same result.
<escape>### </escape>Implementing request coalescing in C#
For an application running on a single process, SemaphoreSlim can help coordinate requests for the same cache key.
The following example uses StackExchange.Redis:
using StackExchange.Redis;
using System.Collections.Concurrent;
using System.Text.Json;
public class ProductCacheService
{
private readonly IDatabase _redis;
private readonly ConcurrentDictionary<
string,
SemaphoreSlim> _locks = new();
public ProductCacheService(IConnectionMultiplexer connection)
{
_redis = connection.GetDatabase();
}
public async Task<Product?> GetProductAsync(
int productId)
{
var cacheKey = $"product:{productId}";
var cachedValue = await _redis.StringGetAsync(
cacheKey);
if (!cachedValue.IsNull)
{
return JsonSerializer.Deserialize<Product>(
cachedValue.ToString());
}
var semaphore = _locks.GetOrAdd(
cacheKey,
_ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync();
try
{
// Check Redis again because another request
// may have populated the cache while waiting.
cachedValue = await _redis.StringGetAsync(
cacheKey);
if (!cachedValue.IsNull)
{
return JsonSerializer.Deserialize<Product>(
cachedValue.ToString());
}
var product = await LoadProductFromDatabaseAsync(
productId);
if (product is null)
{
return null;
}
var json = JsonSerializer.Serialize(product);
await _redis.StringSetAsync(
cacheKey,
json,
TimeSpan.FromMinutes(10));
return product;
}
finally
{
semaphore.Release();
}
}
private Task<Product?> LoadProductFromDatabaseAsync(
int productId)
{
// Replace with the actual database query.
throw new NotImplementedException();
}
}
public class Product
{
public int ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public decimal Price { get; set; }
}The important part is the second Redis check after acquiring the semaphore.
Without this check, requests that were waiting could still execute unnecessary database queries even after the first request populated the cache.
Important production considerations
This implementation provides request coalescing only within one application process.
It does not coordinate requests across multiple application instances.
For example, if an application has five instances, each instance can have its own SemaphoreSlim for the same cache key.
That means up to five database queries may still execute during a simultaneous cache miss.
Also, the dictionary of locks needs a lifecycle strategy. A long-running application that creates locks for an unlimited number of unique keys can retain unnecessary objects in memory.
For a small, controlled set of cache keys, this may be manageable. For high-cardinality workloads, use a lock manager with safe cleanup or a bounded single-flight implementation.
Using Redis Distributed Locks
When an application runs across multiple instances, a distributed lock can coordinate cache regeneration across the entire application.
Redis supports atomic operations that allow an application to acquire a lock only when the lock key does not already exist.
The SET command supports the NX and PX options:
NX creates the key only if it does not already exist.
PX assigns an expiration time in milliseconds.
For example:
SET lock:product:1001 unique-token NX PX 10000If the command succeeds, the application owns the lock for the specified period.
If another application instance attempts to acquire the same lock, it receives a result indicating that the lock is already held.
Distributed lock workflow
Consider three application instances receiving requests for the same product.
API 1 --------\
API 2 --------- Redis
API 3 --------/
API 1 acquires lock:product:1001
API 2 waits for the cache
API 3 waits for the cache
API 1 queries database
API 1 updates Redis
API 1 releases the lock
API 2 and API 3 read the cached resultOnly one instance performs the expensive database operation.
Implementing a Redis lock
The following example demonstrates the basic lock acquisition and safe release pattern.
using StackExchange.Redis;
public class RedisLockService
{
private readonly IDatabase _database;
public RedisLockService(
IConnectionMultiplexer connection)
{
_database = connection.GetDatabase();
}
public async Task<bool> TryAcquireLockAsync(
string lockKey,
string lockToken,
TimeSpan expiration)
{
return await _database.StringSetAsync(
lockKey,
lockToken,
expiration,
When.NotExists);
}
public async Task ReleaseLockAsync(
string lockKey,
string lockToken)
{
const string script = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""";
await _database.ScriptEvaluateAsync(
script,
new RedisKey[] { lockKey },
new RedisValue[] { lockToken });
}
}The release operation uses a Lua script to ensure that an application deletes the lock only when the stored token matches its own token.
This is important because a lock may expire and another application instance may acquire it before the original owner attempts to release it.
A simple DEL operation could accidentally delete the newer owner's lock.
Join the conversation! Your thoughts help the community grow.