The leaderboard query takes 4 seconds, so it is cached for 5 minutes with Cache::remember(). Most of the time the page renders in 30 ms. Every 5 minutes, at the moment the key expires, the database CPU goes to 100%, p99 latency hits 20 seconds and a few requests fail. During those 4 seconds of recomputation, every request that arrives sees a cache miss and starts the same 4-second query. At 300 requests per second that is 1,200 identical queries fighting each other, each one slower because of the others.
That is a cache stampede, also called the dogpile effect or thundering herd. The cache works perfectly at steady state and creates a synchronized burst of load exactly when it expires. There are four standard defenses: locking so only one caller recomputes, probabilistic early expiration (XFetch) so recomputation happens before expiry, stale-while-revalidate so users never wait for it, and TTL jitter so thousands of keys do not expire together. Laravel ships two of them out of the box.
Where the Herd Comes From
namespace App\Http\Controllers;
use App\Services\Leaderboard;
use Illuminate\Support\Facades\Cache;
final class LeaderboardController
{
public function __invoke(Leaderboard $board): array
{
// 4 second query, cached for 5 minutes. Every 5 minutes, every request
// that arrives during those 4 seconds runs the query too.
return Cache::remember('leaderboard:global', 300, fn () => $board->compute());
}
}
Cache::remember() is "get, and on miss compute and put". It has no coordination between processes. The more expensive the computation and the higher the traffic, the more callers land in the miss window: the expected number of duplicate computations is roughly request rate times computation time. A 4-second query at 300 rps means about 1,200 copies. A 50 ms query at 20 rps is one copy, which is why stampedes only show up on hot, expensive keys and are easy to miss in staging.
The same thing happens at a larger scale after a cache flush, a Redis failover, or a deploy that changes key prefixes: every key is cold at once.
The Four Defenses at a Glance
- Lock on miss: the first caller takes a lock and recomputes; others wait or get a fallback. Guarantees one computation per expiry, at the cost of waiting.
- Probabilistic early expiration: each reader, with a probability that rises as expiry approaches, decides to recompute early. One reader usually refreshes the key while everyone else still gets a hit. No locks needed.
- Stale-while-revalidate: keep serving the old value past its freshness window while a single refresh runs in the background. Users never wait.
- TTL jitter: randomize expiry times so keys populated together do not expire together.
Defense 1: A Lock with a Double-Check
Laravel's atomic locks work on Redis, Memcached, DynamoDB, the database and the array store. block() waits up to N seconds to acquire the lock and throws LockTimeoutException if it cannot.
namespace App\Cache;
use Closure;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
final class LockedRemember
{
/**
* Only one process recomputes a missing key; the others wait briefly
* and read the value the winner stored.
*/
public static function get(string $key, int $ttl, Closure $compute, int $waitSeconds = 5): mixed
{
$value = Cache::get($key);
if ($value !== null) {
return $value;
}
try {
return Cache::lock("lock:{$key}", 30)->block($waitSeconds, function () use ($key, $ttl, $compute) {
// Double-check: the previous lock holder probably filled it already.
$value = Cache::get($key);
if ($value !== null) {
return $value;
}
$value = $compute();
Cache::put($key, $value, $ttl);
return $value;
});
} catch (LockTimeoutException) {
// The winner is too slow. Degrade instead of piling onto the database.
return Cache::get($key) ?? throw new \RuntimeException("{$key} unavailable, try again");
}
}
}
Two details make or break this pattern. The double-check inside the lock: by the time a waiting process gets the lock, the winner has already stored the value, so the waiter must read again instead of recomputing. And the lock TTL (30 seconds here) must be longer than the worst-case compute time, otherwise the lock expires mid-computation and a second process starts. If compute time is unpredictable, prefer an owner-checked release (Laravel's release() only frees a lock you own) and a generous TTL.
The weakness is waiting. With 1,200 waiting requests, you have traded a database stampede for a pile of PHP-FPM workers blocked on a lock. That is better, but on a really hot key you want nobody to wait.
Defense 2: Probabilistic Early Expiration (XFetch)
XFetch, from the 2015 VLDB paper Optimal Probabilistic Cache Stampede Prevention by Vattani, Chierichetti and Lowenstein, stores two extra numbers with each value: delta, how long the last computation took, and expiry. On every read it recomputes when
now - delta * beta * ln(random()) >= expiry with random() in (0, 1]
-ln(random()) is an exponentially distributed number with mean 1, so each reader pretends the clock is a little later than it is. Far from expiry, almost nobody crosses the line. As expiry approaches, the probability rises smoothly, and it rises earlier for keys that are expensive to compute (bigger delta), which is exactly where you need the head start. beta tunes eagerness: 1.0 is the paper's recommendation, values above 1 favor earlier refreshes.
namespace App\Cache;
use Closure;
use Illuminate\Contracts\Cache\Repository;
/**
* Probabilistic early expiration (XFetch, Vattani et al., VLDB 2015).
* Each entry stores the value, how long it took to compute (delta) and its logical expiry.
*/
final class XFetch
{
public function __construct(
private Repository $cache,
private float $beta = 1.0,
private ?Closure $clock = null,
private ?Closure $random = null,
) {
$this->clock ??= fn (): float => microtime(true);
$this->random ??= fn (): float => mt_rand(1, mt_getrandmax()) / mt_getrandmax(); // (0, 1]
}
public function remember(string $key, int $ttl, Closure $compute): mixed
{
$entry = $this->cache->get($key);
$now = ($this->clock)();
if (is_array($entry)) {
[$value, $delta, $expiry] = $entry;
// log(random) <= 0, so this subtracts a random head start that grows
// with compute time (delta) and with beta.
if ($now - $delta * $this->beta * log(($this->random)()) < $expiry) {
return $value;
}
}
$start = ($this->clock)();
$value = $compute();
$delta = ($this->clock)() - $start;
// Keep the physical TTL a bit longer than the logical one so late
// readers still find the old value while someone recomputes.
$this->cache->put($key, [$value, $delta, $start + $delta + $ttl], $ttl + (int) ceil($delta) + 60);
return $value;
}
}
There are no locks, no waiting and no background workers. Keep the physical TTL longer than the logical expiry so that the value stays readable while one reader recomputes.
Defense 3: Stale-While-Revalidate with Cache::flexible
Since Laravel 11, Cache::flexible() implements stale-while-revalidate directly. You give it two durations: how long the value is fresh, and how long a stale value may still be served.
use App\Services\Leaderboard;
use Illuminate\Support\Facades\Cache;
// Fresh for 60s. Between 60s and 300s the stale value is returned immediately
// and ONE refresh runs after the response is sent. After 300s it is a normal miss.
$board = Cache::flexible('leaderboard:global', [60, 300], function () {
return app(Leaderboard::class)->compute();
});
Inside the fresh window it is a normal hit. Inside the stale window the caller gets the old value immediately, and Laravel schedules a refresh with defer(), so it runs after the response has been sent to the user, guarded by a lock so only one process refreshes. Past the stale window it behaves like remember(). Pick the stale window based on how wrong the data may be, not on performance: a leaderboard can be 5 minutes stale, an account balance cannot be stale at all.
Note that deferred work runs in the same PHP-FPM worker after the response, so it still occupies that worker. Keep the refresh itself reasonably fast, or dispatch a queued job from the closure for very slow recomputations.
Defense 4: TTL Jitter
Locks and XFetch protect one hot key. Jitter protects you from thousands of medium-hot keys that were written at the same moment, typically by a cache warmer or a deploy:
use Illuminate\Support\Facades\Cache;
// 10,000 product pages warmed by the same deploy expire at the same second
// unless you spread the TTLs out.
function ttlWithJitter(int $base, float $spread = 0.1): int
{
$delta = (int) round($base * $spread);
return $base + random_int(-$delta, $delta);
}
Cache::put('product:42', ['id' => 42], ttlWithJitter(3600));
Ten percent spread is usually enough. Combine it with any of the other three.
Testing Stampede Protection
The key to testing XFetch is injecting the clock and the random source, so "unlucky" and "lucky" draws are deterministic. exp(-5) as the random value produces a head start of exactly 5 * delta. The lock tests run on the array store, which supports atomic locks: one checks that the value is computed once and reused, the other holds the lock as if another process were mid-computation and asserts that the caller gives up after the wait instead of running the query.
// tests/Unit/XFetchTest.php
use App\Cache\LockedRemember;
use App\Cache\XFetch;
use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
$this->now = 1000.0;
$this->rand = 1.0;
$this->calls = 0;
$this->cache = new XFetch(
new Repository(new ArrayStore()),
beta: 1.0,
clock: fn () => $this->now,
random: fn () => $this->rand,
);
$this->compute = function () {
$this->calls++;
$this->now += 2.0; // the expensive query takes 2 seconds
return "v{$this->calls}";
};
});
it('computes once on a miss and then serves from cache', function () {
expect($this->cache->remember('k', 60, $this->compute))->toBe('v1');
$this->now += 10;
expect($this->cache->remember('k', 60, $this->compute))->toBe('v1')
->and($this->calls)->toBe(1);
});
it('recomputes early when the random draw is unlucky', function () {
$this->cache->remember('k', 60, $this->compute); // expiry = 1062
$this->now = 1055.0;
$this->rand = exp(-5); // head start = 2 * 5 = 10s
expect($this->cache->remember('k', 60, $this->compute))->toBe('v2');
});
it('never recomputes early with a lucky draw', function () {
$this->cache->remember('k', 60, $this->compute);
$this->now = 1061.0;
$this->rand = 1.0; // log(1) = 0, no head start
expect($this->cache->remember('k', 60, $this->compute))->toBe('v1');
});
it('computes once and reuses the stored value', function () {
$runs = 0;
$compute = function () use (&$runs) {
$runs++;
return 'fresh';
};
foreach (range(1, 20) as $_) {
expect(LockedRemember::get('report', 60, $compute))->toBe('fresh');
}
expect($runs)->toBe(1);
});
it('does not compute while another process holds the lock', function () {
$runs = 0;
Cache::lock('lock:report', 30)->get(); // simulate the winner, still computing
expect(fn () => LockedRemember::get('report', 60, function () use (&$runs) {
$runs++;
return 'fresh';
}, waitSeconds: 1))->toThrow(RuntimeException::class);
expect($runs)->toBe(0);
});
These tests prove the logic. For confidence under real concurrency, run a short load test (k6, ab, or hey) against a staging endpoint with a tiny TTL and count how many times the computation runs, for example with a Redis INCR inside the closure.
Common Pitfalls
- No double-check after acquiring the lock. Every waiter recomputes in turn: you serialized the stampede instead of preventing it.
- Lock TTL shorter than compute time. The lock expires, a second process enters, and the first one later releases a lock it no longer owns (Laravel's owner check stops that release, but the duplicate work already happened).
- Caching
nullorfalsewithremember(). A computation that legitimately returnsnullis never cached, so every request misses. Wrap it, for example cache an empty array or a sentinel object. - Using
random()that can return 0.ln(0)is negative infinity and forces a recompute on every read. Draw from (0, 1]. - Stale windows on data that must be correct. Stale-while-revalidate is a product decision. Prices at checkout, permissions and balances need invalidation on write, not stale reads.
- Forgetting cold starts. All four defenses assume most keys are warm. After a full flush, warm the hottest keys before shifting traffic, or rate-limit the recompute path.
- Holding locks across slow external calls. If the computation calls a third-party API, a timeout there blocks every waiter. Set explicit HTTP timeouts shorter than the lock TTL.
When to Use Which, and When Not To
- Low traffic or cheap computation: plain
remember()is fine. Do not add locks to a 20 ms query hit twice per minute. - Hot key, staleness acceptable:
Cache::flexible(). It is the easiest win in a Laravel app and users never wait. - Hot key, expensive computation, no background work available (CLI, long-running workers, other frameworks): XFetch.
- Must never compute twice (calls to a paid API, rate-limited upstream): a lock with double-check, possibly combined with flexible.
- Many keys written together: add jitter regardless of the other choices.
Do not use caching tricks to hide a query that should be fixed. If the leaderboard takes 4 seconds, a summary table updated incrementally may remove the need for any of this.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
Implement probabilistic early expiration (XFetch). First write shouldRecompute(now, expiry, delta, beta, random) returning true when now - delta * beta * ln(random) >= expiry, where ln is the natural logarithm and random is in (0, 1]. Then implement get(key, ttl, compute) on XFetchCache, whose constructor receives an injected clock function, an injected random function and beta (default 1.0). If an entry exists for key and shouldRecompute(clock(), entry expiry, entry delta, beta, random()) is false, return the cached value. Otherwise recompute: read start = clock(), call compute(), read end = clock(), store the value with delta = end - start and expiry = end + ttl, and return the value. Call random() only when an entry exists (never on a miss), and keep entries per key.
Starter code
/**
* XFetch decision: recompute when now - delta * beta * ln(random) >= expiry.
* $random is in (0, 1].
*/
function shouldRecompute(float $now, float $expiry, float $delta, float $beta, float $random): bool
{
// TODO: implement the XFetch formula (log() is the natural logarithm)
throw new LogicException('TODO');
}
final class XFetchCache
{
/** @var array<string, array{value: mixed, delta: float, expiry: float}> */
private array $entries = [];
public function __construct(
private Closure $clock,
private Closure $random,
private float $beta = 1.0,
) {}
public function get(string $key, float $ttl, Closure $compute): mixed
{
// TODO: if an entry exists and shouldRecompute(...) is false, return its value
// (call $this->random only when an entry exists)
// TODO: otherwise: start = clock(), value = compute(), delta = clock() - start,
// store value, delta and expiry = clock() + ttl, return value
throw new LogicException('TODO');
}
}
/**
* XFetch decision: recompute when now - delta * beta * ln(random) >= expiry.
* random is in (0, 1].
*/
function shouldRecompute(now, expiry, delta, beta, random) {
// TODO: implement the XFetch formula (Math.log is the natural logarithm)
throw new Error('TODO');
}
class XFetchCache {
constructor(clock, random, beta = 1.0) {
this.clock = clock;
this.random = random;
this.beta = beta;
this.entries = new Map();
}
get(key, ttl, compute) {
// TODO: if an entry exists and shouldRecompute(...) is false, return its value
// (call this.random only when an entry exists)
// TODO: otherwise: start = clock(), value = compute(), delta = clock() - start,
// store value, delta and expiry = clock() + ttl, return value
throw new Error('TODO');
}
}
Your solution
Hints
All hints revealedln(random) is zero or negative, so subtracting delta * beta * ln(random) moves 'now' forward
Read the clock before and after compute(): their difference is delta, and expiry is counted from the end time
Short-circuit on a missing entry before calling random() so the random source is not consumed on a miss
/**
* XFetch decision: recompute when now - delta * beta * ln(random) >= expiry.
* $random is in (0, 1].
*/
function shouldRecompute(float $now, float $expiry, float $delta, float $beta, float $random): bool
{
return $now - $delta * $beta * log($random) >= $expiry;
}
final class XFetchCache
{
/** @var array<string, array{value: mixed, delta: float, expiry: float}> */
private array $entries = [];
public function __construct(
private Closure $clock,
private Closure $random,
private float $beta = 1.0,
) {}
public function get(string $key, float $ttl, Closure $compute): mixed
{
$entry = $this->entries[$key] ?? null;
if ($entry !== null && !shouldRecompute(
($this->clock)(),
$entry['expiry'],
$entry['delta'],
$this->beta,
($this->random)(),
)) {
return $entry['value'];
}
$start = ($this->clock)();
$value = $compute();
$end = ($this->clock)();
$this->entries[$key] = [
'value' => $value,
'delta' => $end - $start,
'expiry' => $end + $ttl,
];
return $value;
}
}
/**
* XFetch decision: recompute when now - delta * beta * ln(random) >= expiry.
* random is in (0, 1].
*/
function shouldRecompute(now, expiry, delta, beta, random) {
return now - delta * beta * Math.log(random) >= expiry;
}
class XFetchCache {
constructor(clock, random, beta = 1.0) {
this.clock = clock;
this.random = random;
this.beta = beta;
this.entries = new Map();
}
get(key, ttl, compute) {
const entry = this.entries.get(key);
if (entry && !shouldRecompute(this.clock(), entry.expiry, entry.delta, this.beta, this.random())) {
return entry.value;
}
const start = this.clock();
const value = compute();
const end = this.clock();
this.entries.set(key, { value, delta: end - start, expiry: end + ttl });
return value;
}
}

