A cache stampede happens when many requests miss the same expired cache key and regenerate it at once. This Laravel guide explains the failure mode and shows practical fixes using atomic locks, stale-while-revalidate, TTL jitter, and safe fallbacks.
A cache stampede happens when many concurrent requests discover that the same popular cache entry is missing or expired, then all try to rebuild it at the same time. Instead of protecting the database, the cache suddenly funnels a burst of duplicate work toward it. The pattern is also called the thundering herd or dog-pile effect.
Imagine a Laravel application caches a costly “top products” query for 10 minutes. While the key exists, thousands of requests receive the cached result quickly. At the exact moment the key expires, 200 requests arrive. Every request sees a miss, every request runs the same query, and every request tries to write the same value back. A query that should run once now runs 200 times.
This short burst can exhaust database connections, saturate CPU, increase response times, and trigger timeouts in dependent services. Those slower responses keep requests open longer, which raises concurrency and can turn one expired key into a cascading failure.
Laravel's Cache::remember is an excellent default for ordinary caching:
$products = Cache::remember('products:top', 600, function () {
return Product::query()
->where('is_active', true)
->orderByDesc('sales_count')
->limit(20)
->get();
});It checks the key, executes the closure on a miss, stores the result, and returns it. However, the read and the regeneration are not one atomic operation. If several PHP workers observe the miss before the first worker finishes the query, each worker can enter the closure. The code is correct under low traffic but vulnerable when a hot key expires under high concurrency.
A hot key expires: one frequently requested item reaches its TTL during a traffic peak.
Many keys share the same TTL: a bulk cache warm-up causes hundreds of entries to expire together.
The cache is flushed: a deployment or maintenance command removes popular keys at once.
A cold start occurs: new application instances receive traffic before important values are warmed.
Regeneration is slow: expensive SQL, remote API calls, or report generation gives more requests time to pile up.
The cache store fails: a Redis or Memcached interruption redirects far more work to the origin than it can absorb.
The central idea is request coalescing: allow one request to regenerate the value while the others wait briefly or use a fallback. Laravel provides distributed atomic locks through Cache::lock.
use Illuminate\Support\Facades\Cache;
function topProducts()
{
$key = 'products:top';
if ($cached = Cache::get($key)) {
return $cached;
}
return Cache::lock("lock:{$key}", 15)->block(5, function () use ($key) {
// Another worker may have populated the key while we waited.
return Cache::get($key) ?? tap(
Product::query()
->where('is_active', true)
->orderByDesc('sales_count')
->limit(20)
->get(),
fn ($products) => Cache::put($key, $products, now()->addMinutes(10))
);
});
}The second cache check inside the lock is essential. Without it, every waiting request would acquire the lock in turn and recompute the same value even though the first request already refreshed it. This pattern is often called double-checked locking.
Choose a lock lifetime longer than the expected regeneration time, but not so long that a failed worker blocks refreshes for an excessive period. Also set a short maximum wait with block. When the wait expires, Laravel throws LockTimeoutException; production code should decide whether to return stale data, a reduced response, or a controlled error.
For content that can be slightly out of date, stale-while-revalidate usually gives the best user experience. Laravel's Cache::flexible defines a fresh window and a stale window:
$products = Cache::flexible(
'products:top',
[300, 900],
fn () => Product::query()
->where('is_active', true)
->orderByDesc('sales_count')
->limit(20)
->get()
);During the first 300 seconds, Laravel returns the fresh value. Between 300 and 900 seconds, it immediately serves the stale value and registers a deferred refresh after the response. After 900 seconds, the value is fully expired and must be recalculated synchronously.
This approach keeps request latency predictable and reduces the number of users exposed to an expensive refresh. It works especially well for article lists, product rankings, dashboards, recommendations, and other data where a few minutes of staleness is acceptable.
If many keys are created together with the same lifetime, they can expire together. Add a small random offset—called TTL jitter—to spread refreshes across time:
$ttl = now()->addSeconds(600 + random_int(0, 120));
Cache::put("product:{$product->id}", $payload, $ttl);Jitter does not prevent multiple requests from racing on one hot key, so it complements locks or stale-while-revalidate rather than replacing them. Its purpose is to avoid a synchronized expiration wave across many keys.
High-traffic systems often combine a normal key, a longer-lived stale key, and an atomic lock. One worker refreshes the data. Other workers either wait briefly or return the stale copy.
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
function dashboardStats(): array
{
$key = 'dashboard:stats';
$staleKey = 'dashboard:stats:stale';
if ($value = Cache::get($key)) {
return $value;
}
try {
return Cache::lock("lock:{$key}", 20)->block(2, function () use ($key, $staleKey) {
if ($value = Cache::get($key)) {
return $value;
}
$value = app(StatsService::class)->calculate();
$freshTtl = now()->addSeconds(300 + random_int(0, 60));
Cache::put($key, $value, $freshTtl);
Cache::put($staleKey, $value, now()->addHours(2));
return $value;
});
} catch (LockTimeoutException) {
return Cache::get($staleKey, [
'status' => 'temporarily_unavailable',
]);
}
}This design protects the database and gives callers a defined degradation path. Adjust the values to the cost of the query, acceptable staleness, expected traffic, and failure characteristics of your cache store.
All application servers must use a shared cache backend that supports atomic locks. Laravel documents support for drivers including Redis, Memcached, DynamoDB, the database, file, and array stores, but multi-server deployments must communicate with the same central store. Redis is a common production choice.
Use a unique lock name per resource. Include tenant IDs, locale, filters, or model IDs when they change the cached result.
Keep the lock lease bounded. A worker may crash, so the lock must eventually expire.
Do not use a database lock stored in the same overloaded database without considering whether it worsens the bottleneck.
Do not blindly release someone else's lock. Let Laravel's closure form release the lock automatically when possible.
Cache empty results deliberately. A truthy check can treat an empty collection as a miss. Use Cache::has or a sentinel when empty values are valid.
Avoid one global lock. A coarse lock serializes unrelated work and creates unnecessary latency.
Look for periodic spikes that align with TTL boundaries. Useful signals include a sudden drop in cache hit rate, a burst of identical SQL queries, rising database connections, lock contention, increased p95 or p99 latency, and timeouts shortly after a cache flush or deployment.
Instrument the expensive closure, not only the cache call. Record the cache key family, regeneration duration, lock acquisition time, lock timeouts, and whether stale data was served. In Laravel, query listeners, application metrics, tracing, and Redis monitoring can make the pattern visible. A load test that sends concurrent requests immediately after deleting one hot key is a simple way to verify the fix.
Use Cache::remember for inexpensive values or low-concurrency keys.
Use Cache::lock when regeneration must run once and callers can wait briefly.
Use Cache::flexible when slightly stale data is acceptable and fast responses matter.
Add TTL jitter when many related keys are written together.
Use proactive warming for a small set of predictable, business-critical hot keys.
Combine locks, stale fallback, jitter, and monitoring for the highest-traffic paths.
No. A cache miss is normal: one request cannot find a value and recomputes it. A stampede occurs when many concurrent requests miss the same value and repeat the expensive work simultaneously.
No. Redis makes cache access fast and provides primitives for atomic locking, but application code still needs a regeneration strategy. Laravel's lock and flexible-cache APIs provide the building blocks.
No. Locks add coordination and can add latency. Protect hot keys whose regeneration is expensive enough to threaten a backend under concurrency.
For data that may be stale, start with Cache::flexible. For data that must be regenerated once, wrap the miss path in Cache::lock and check the cache again after acquiring the lock.
A cache stampede is a concurrency problem disguised as a caching problem. The solution is not merely a longer TTL. Coordinate regeneration, serve stale data where acceptable, spread expirations with jitter, and measure what happens when a hot key disappears. Laravel gives you the essential tools—Cache::lock, Cache::flexible, and flexible cache stores—to keep one expired key from becoming a production incident.
No approved comments are visible yet. New community replies may wait for moderation.