- What: A cache stampede is many requests recomputing one expired hot key at the same time.
- Why it matters: The burst lands on your database exactly when the cache stops protecting it.
- What to do: Combine a per-key lock for cold keys with early refresh or stale-while-revalidate for warm ones.
- Key stat: Facebook’s lease mechanism cut peak database load from 17K to 1.3K queries per second on stampede-prone keys.
Cache stampede prevention stops many concurrent requests from rebuilding the same expired cache entry at once. Use a per-key lock so one process recomputes while others wait or read a stale copy, and refresh hot keys early with probabilistic expiration or stale-while-revalidate. Symfony Cache, Laravel Cache::flexible and Next.js ISR each cover part of this.
A cache stampede (also called dog-piling) is a cascading failure in which a popular cached value expires under high concurrency and many processes recompute it simultaneously, overloading the shared backend. Stampede prevention techniques fall into three groups: locking, external recomputation, and probabilistic early expiration.
Cache stampede prevention is the part of caching most teams skip until a dashboard query takes the database down at the top of the hour. Read the key, rebuild on a miss, write it back: that is safe at low traffic. At high traffic, every request arriving during the rebuild shares the miss. Wikipedia’s worked example is a page that takes 3 seconds to render under 10 requests per second: when it expires, 30 processes recompute it at once. Symfony’s documentation gives a similar case where a 5-second computation hit 10 times per second is computed about 50 times before the cache is warm again.
This guide covers the three techniques that work, with PHP and Node.js code, and the gaps in Laravel, Symfony and Next.js that most tutorials miss. For a refresher on cache layers, see our guide to advanced caching strategies.
What causes a cache stampede?
A cache stampede needs three things at once: a hot key, an expensive recompute, and an expiry every reader sees at the same instant. Remove one and it disappears.
PHP makes this easy to trigger: each PHP-FPM worker is an isolated process, so no worker knows another is already rebuilding the key. Typical cache-aside code:
<?php
// Broken under load: every concurrent miss recomputes the same value.
function getDashboardStats(Redis $redis): array
{
$cached = $redis->get('stats:dashboard');
if ($cached !== false) {
return json_decode($cached, true);
}
$stats = buildStatsFromDatabase(); // slow aggregate query
$redis->set('stats:dashboard', json_encode($stats), ['ex' => 3600]);
return $stats;
}
Every request between the expiry and the set() call runs the slow query. The Wikipedia article notes the worst case: under heavy enough load, every rebuild attempt times out, so the value is never re-cached and the system stays in congestion collapse.
Identical TTLs on keys warmed together make it worse, because they all expire together. A small random offset per key spreads them out, but jitter does nothing for one very hot key.
How does lock-based cache stampede prevention work?
Lock-based cache stampede prevention lets one process recompute a missing key while the others wait or read a stale copy. It is the only technique that also protects a completely cold key.
In Redis, the building block is SET key value NX EX seconds: NX sets the key only if it is absent, so one caller wins. The Redis docs call this a simple way to implement a locking system, and note the options were added in Redis 2.6.12.
<?php
// Fixed: one process rebuilds, the rest wait briefly or serve a stale copy.
function getDashboardStats(Redis $redis): array
{
$key = 'stats:dashboard';
if (($hit = $redis->get($key)) !== false) {
return json_decode($hit, true);
}
$token = bin2hex(random_bytes(16));
if ($redis->set("lock:$key", $token, ['nx', 'ex' => 10])) {
try {
$stats = buildStatsFromDatabase();
$json = json_encode($stats);
$redis->set($key, $json, ['ex' => 3600]);
$redis->set("stale:$key", $json, ['ex' => 86400]); // fallback copy
return $stats;
} finally {
// Only delete the lock if we still own it.
$redis->eval(
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end return 0',
["lock:$key", $token],
1
);
}
}
// Lost the race: serve stale data if we have it, otherwise poll briefly.
if (($stale = $redis->get("stale:$key")) !== false) {
return json_decode($stale, true);
}
for ($i = 0; $i < 20; $i++) {
usleep(50_000);
if (($hit = $redis->get($key)) !== false) {
return json_decode($hit, true);
}
}
throw new RuntimeException('stats:dashboard unavailable');
}
Lock gotchas that break in production
- Always set a lock TTL. A crashed holder must not block the key forever.
- Release only your own lock. Use a random token and a compare-and-delete script; a plain
DELcan remove another worker’s lock. - Keep a stale fallback. Polling workers still tie up processes; a stale copy keeps latency flat.
- Know the guarantees. The Redis docs mark the single-instance pattern as discouraged in favor of Redlock when you need fault tolerance. For stampede control, a lost lock just means one extra recompute.
Locking can prevent stampedes altogether, at the cost of an extra write and edge cases like crashed holders. The same token-and-TTL discipline applies to idempotency keys in PHP, which solve a related duplicate-work problem on the write side.
How does Facebook’s lease mechanism stop thundering herds?
Facebook’s leases are a lock built into the cache server: on a miss, the server hands out a token, and only its holder may write the value back. The 2013 NSDI paper Scaling Memcache at Facebook describes the thundering herd tweak.
Each memcached server returns a token for a given key only once every 10 seconds. Other clients are told to wait briefly and retry, and by then the value is usually cached.
Over one week of misses on herd-prone keys, peak database load was 17K queries per second without leases and 1.3K with them.
The lesson: decide “who rebuilds” close to the cache, and give losers a cheap retry instead of a full recompute.
What is probabilistic early expiration (XFetch)?
Probabilistic early expiration lets each read independently decide to refresh a value slightly before it expires, with a chance that rises near expiry. Usually one reader refreshes early while the rest keep hitting the cache.
The optimal form is XFetch, from the VLDB 2015 paper Optimal Probabilistic Cache Stampede Prevention by Vattani, Chierichetti and Lowenstein (PVLDB 8(8), pages 886–897). It stores the recompute time (delta) with the value and triggers an early refresh when now − delta × beta × ln(rand()) reaches the expiry.
<?php
// XFetch: recompute early with a probability that rises near expiry.
function xfetch(Redis $redis, string $key, int $ttl, callable $recompute, float $beta = 1.0): mixed
{
$raw = $redis->get($key);
if ($raw !== false) {
['v' => $value, 'delta' => $delta, 'expiry' => $expiry] = json_decode($raw, true);
$rand = mt_rand(1, mt_getrandmax()) / mt_getrandmax(); // (0, 1], never log(0)
if (microtime(true) - $delta * $beta * log($rand) < $expiry) {
return $value; // not this request's turn to refresh
}
}
$start = microtime(true);
$value = $recompute();
$delta = microtime(true) - $start; // how long recompute took
$redis->set($key, json_encode([
'v' => $value, 'delta' => $delta, 'expiry' => microtime(true) + $ttl,
]), ['ex' => $ttl]);
return $value;
}
Three details matter. First, rand must never be 0, or log() returns negative infinity and every read refreshes. Second, beta=1 is the paper’s recommended default; according to the Wikipedia summary of the paper, values above 1 favour earlier recompute. Third, XFetch adds memory, because delta and expiry are stored alongside every value.
XFetch’s weakness is the cold key. If the value is absent, there is nothing to refresh early, and every concurrent reader recomputes. Pair it with a lock for cold misses.
Does cache stampede prevention in Laravel, Symfony and Next.js cover cold keys?
Not fully. Each ships part of the solution, with a gap tutorials skip.
Symfony Cache Contracts
Symfony added probabilistic early expiration to the Cache component in Symfony 4.2, released in November 2018. The third argument of get() is beta, which defaults to 1.0; set it to 0 to disable early recompute or INF to force it.
<?php
use Symfony\Contracts\Cache\ItemInterface;
$stats = $cache->get('stats.dashboard', function (ItemInterface $item): array {
$item->expiresAfter(3600);
return buildStatsFromDatabase();
}, 1.0); // beta: 0 disables early recompute, INF forces it
The catch is in the current Symfony Cache docs: the built-in lock allows one PHP process to compute a key “on a per-host basis”. With ten web servers, a cold key can still be computed ten times. The docs also state that stampede prevention applies only to the Cache Contracts, so code using PSR-6 getItem()/save() gets no protection.
Laravel Cache::flexible
Laravel’s Cache::flexible implements stale-while-revalidate. You pass two durations, such as [5, 10]: inside the first the value is fresh; between the two, the stale value is served and a deferred function refreshes it after the response is sent.
The stale refresh runs inside a cache lock. The cold path does not. In the framework source shown in laravel/framework PR #57651, when the value or its timestamp key is missing, flexible() calls your closure directly. After a deploy, cache flush or eviction, every concurrent request recomputes. Once the second duration passes, the docs confirm the value “is recalculated immediately”. The fix is a lock on the cold path:
<?php
use Illuminate\Support\Facades\Cache;
// Warm path: fresh for 5 min, served stale up to 15 min while it refreshes.
// Cold path: wrap the miss in a lock so only one worker rebuilds.
function dashboardStats(): array
{
$build = fn () => Cache::flexible('stats:dashboard', [300, 900], fn () => buildStatsFromDatabase());
if (Cache::has('stats:dashboard')) {
return $build();
}
return Cache::lock('stats:dashboard:cold', 15)->block(5, $build);
}
The same PR documents a second gotcha: flexible() stores the value and a separate “created” timestamp key, and reads both with one multi-key call. On AWS ElastiCache Serverless (Valkey), which runs with cluster mode enabled behind one endpoint, that read fails with a CROSSSLOT error because the keys hash to different slots. The PR was closed without merging, so test flexible() on your actual cache topology.
Node.js and Next.js
Node.js has an advantage PHP-FPM lacks: one long-lived process serves many concurrent requests, so misses can be coalesced in memory. In this “single-flight” pattern, every concurrent caller in the process awaits the same promise:
// Node.js: concurrent callers in one process share a single in-flight promise.
const inflight = new Map();
export async function getCached(redis, key, ttlSec, recompute) {
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit);
if (!inflight.has(key)) {
inflight.set(key, (async () => {
try {
const value = await recompute();
await redis.set(key, JSON.stringify(value), { EX: ttlSec });
return value;
} finally {
inflight.delete(key);
}
})());
}
return inflight.get(key);
}
That dedupes within one process only. Across several instances, add the same Redis SET NX lock.
Next.js ISR is stale-while-revalidate for pages. With export const revalidate = 60, a request after the window still gets the cached, now stale page while a new version generates in the background. The caveats matter at scale: the default file-system cache is per-instance, and background regeneration runs on whichever instance received the request. Ten instances can regenerate the same page ten times; the docs point to a shared custom cache handler to coordinate.
When should you use stale-while-revalidate instead of locks?
Use stale-while-revalidate when slightly old data is acceptable and latency matters more than freshness. Use locks when serving old data is wrong, or when you must protect cold keys.
The idea comes from HTTP: RFC 5861 (May 2010) defines stale-while-revalidate and stale-if-error, and application caches borrow both.
My recommendation: decide per key, not per app. Dashboard counts and trending lists tolerate seconds of staleness; balances, permissions and inventory do not. For broader response-time tuning, see our API performance optimization guide.
| Technique | Protects cold keys? | Serves stale data? | Extra cost | PHP support |
|---|---|---|---|---|
| Per-key lock (SET NX EX) | Yes | Optional fallback | Extra write per rebuild; lock TTL tuning | Laravel Cache::lock; Symfony (per host); Node via Redis |
| Leases (Facebook memcache) | Yes | Optional | Requires cache-server support | Not in stock memcached clients |
| XFetch early expiration | No | No | Stores delta and expiry per value | Symfony Cache Contracts (beta = 1.0) |
| Stale-while-revalidate | No | Yes, inside stale window | Extra timestamp key | Laravel Cache::flexible; Next.js ISR |
| In-process single-flight | Yes, within one process | No | Does not span instances | Natural in Node.js; not across PHP-FPM workers |
| External refresh job | Yes, if run before expiry | No | Extra process to run and monitor | Laravel scheduler, Symfony Messenger |
The NexGismo verdict: no single technique covers both cold and warm keys. In any stack, I’d use the framework’s early-refresh feature (Symfony beta, Laravel flexible(), Next.js ISR) for warm keys and a per-key Redis lock with a stale fallback for cold misses. In Node.js, put in-process single-flight in front of it. Reach for an external refresh job only for a small set of known, very expensive keys.
- A cache stampede occurs when a hot key expires and concurrent requests all recompute the same value.
- Facebook’s memcache leases reduced peak database load from 17K to 1.3K queries per second on stampede-prone keys.
- XFetch probabilistic early expiration uses beta = 1 by default and is built into Symfony Cache Contracts since Symfony 4.2.
- Symfony’s built-in stampede lock works per host, and PSR-6 methods get no stampede protection.
- Laravel Cache::flexible locks its background refresh but recomputes cold or fully expired keys without a lock.
- Next.js ISR serves stale pages while regenerating, but its default cache and regeneration are per instance.
Frequently Asked Questions
What is a cache stampede?
A cache stampede happens when a heavily read cache key expires and many concurrent requests miss at the same moment. Each one recomputes the same value, so the database or upstream service receives a burst of identical expensive work. Under enough load the burst can stop the value from ever being re-cached, which keeps the system overloaded.
How do you prevent a cache stampede?
Use one or more of three techniques. A per-key lock lets one process recompute while others wait or get stale data. Probabilistic early expiration (XFetch) refreshes hot keys slightly before they expire. Stale-while-revalidate serves the old value and refreshes after the response. Production systems usually combine a lock with one of the other two.
What is the difference between cache stampede and thundering herd?
The terms overlap. Cache stampede, also called dog-piling, describes many requests recomputing one expired cache entry. Thundering herd is the broader term for many waiting clients waking up and hitting one resource at once. Facebook’s memcache paper uses thundering herd for the cache case and solves it with rate-limited lease tokens.
Does Laravel Cache::flexible prevent cache stampede?
Partly. Cache::flexible serves stale data during the stale window and refreshes it in a deferred function guarded by a lock, so it removes most stampedes on warm keys. When the key is missing entirely, such as after a deploy, flush or eviction, the callback runs without a lock. Add Cache::lock for cold keys you cannot afford to recompute in parallel.
What is probabilistic early expiration?
Probabilistic early expiration is a stampede prevention technique where each read independently decides, with a probability that rises as expiry approaches, to recompute the value early. The XFetch algorithm by Vattani, Chierichetti and Lowenstein uses an exponential distribution scaled by recompute time and a beta parameter. Symfony’s Cache component ships it with beta defaulting to 1.0.
Where to start with cache stampede prevention
Start by finding your hot keys: log misses with key name and recompute time for a day, then sort by misses times duration. Give the top keys a lock with a stale fallback first, because that also covers deploys and flushes. Then enable early refresh for the rest, add TTL jitter to anything warmed in bulk, and test framework features on your real topology, whether that is clustered Valkey or multi-instance Next.js. Have a different fix? Share it with us.
Sources
- Wikipedia — Cache stampede
- Vattani, Chierichetti, Lowenstein — Optimal Probabilistic Cache Stampede Prevention (PVLDB 2015, DOI)
- USENIX NSDI 2013 — Scaling Memcache at Facebook (PDF)
- Symfony Docs — The Cache Component: Preventing Cache Stampede
- Symfony Blog — New in Symfony 4.2: Cache stampede protection
- Laravel 12.x Docs — Cache: Stale While Revalidate and Atomic Locks
- GitHub — laravel/framework PR #57651: Cache::flexible() and ElastiCache Serverless
- Redis Docs — SET command
- IETF RFC 5861 — HTTP Cache-Control Extensions for Stale Content
- Next.js Docs — How to implement Incremental Static Regeneration (ISR)
