Why Cache::remember() melts the thing it was meant to protect
remember() is get-or-compute with no coordination between callers. At low traffic you never notice. At peak it is an outage amplifier.
ON THIS PAGE
The outage that started when the cache worked
The dashboard query takes 4.2 seconds. Someone wrapped it in a cache and the page dropped to 40ms:
$stats = Cache::remember('dashboard:stats', 300, function () {
return $this->expensiveRollup();
});
That held for months. Then a campaign pushed traffic to roughly 400 requests per second, and the site fell over every five minutes — not gradually, in spikes, on a schedule nobody could explain until they overlaid the graph on the TTL.
Here is the schedule. At t=300 the key expires. The next request finds nothing and starts the 4.2-second rollup. During those 4.2 seconds, another 1,600 requests arrive, find nothing in the cache, and each start their own 4.2-second rollup. The first one finishes and writes the key. The other 1,599 finish too, and write the same key again, having already done the damage.
Your cache did not fail. It did exactly what it promises. remember() is get-or-compute, and there is nothing in it that coordinates concurrent callers — no notion that a computation is already in flight. At one request per second the window is empty and you never see it. The pattern is called a cache stampede, and the thing that triggers it is the cache working well enough that the origin can no longer survive being hit directly.
An atomic lock makes exactly one caller do the work
The direct fix is to let one request recompute while the others wait. Laravel's atomic locks do this across processes and servers:
$stats = Cache::get('dashboard:stats') ?? Cache::lock('dashboard:stats:lock', 10)->block(5, function () {
return Cache::remember('dashboard:stats', 300, fn () => $this->expensiveRollup());
});
block(5, ...) waits up to five seconds for the lock and releases it automatically once the closure returns. The winner recomputes; every other request blocks, then reads the value the winner just wrote. One rollup instead of 1,600.
Two details decide whether this helps or hurts.
The first argument to lock() is how long the lock is held before the cache expires it on its own. Make it comfortably longer than the work — a 10-second lock around a 4.2-second job is fine, a 3-second lock around it means a second caller acquires the "free" lock while the first is still running, and you are back to concurrent rollups with extra steps.
If the lock cannot be acquired inside the wait, block() throws Illuminate\Contracts\Cache\LockTimeoutException. Catch it and decide deliberately: serve stale, serve a degraded response, or fail. What you must not do is let 1,600 requests each block for five seconds, because now you have traded a database stampede for exhausted PHP workers. Locking converts a load problem into a latency problem. That is usually the better problem, but it is a different one.
Locks need a cache driver that supports them — memcached, redis, dynamodb, database, file or array — and every server must talk to the same central cache. Two web nodes with their own file caches hold two independent locks and coordinate nothing.
flexible() removes the wait instead of sharing it
Locking still makes somebody wait. Cache::flexible() implements stale-while-revalidate, which makes nobody wait:
$stats = Cache::flexible('dashboard:stats', [300, 600], function () {
return $this->expensiveRollup();
});
The array is two boundaries, not a TTL and a grace period bolted together. Inside the first value the value is fresh and returned immediately. Between the two values it is stale: the old value is returned to this request straight away, and a deferred function is registered to recompute it after the response has been sent. After the second value the entry is gone, and the caller recomputes inline and waits.
So the request that crosses into the stale window pays nothing, and the refresh happens where the user cannot feel it. The cost is honesty about staleness: between 300 and 600 seconds, readers are served data that is up to ten minutes old. For a dashboard rollup that is free. For a balance, an inventory count or anything a user is about to act on, it is a bug you have chosen.
Note where the cliff still is. flexible() removes the stampede at the fresh boundary, not at the stale one. If a key genuinely goes untouched for the whole stale window — a tenant who logs in twice a day, a page nobody visits at 4am — the next request lands past the second value and takes the full recompute, and if a hundred land together, they stampede exactly as before. flexible() is for keys under steady traffic. That is the common case, which is why it is usually the right default, but it is not a guarantee.
The keys that expire together were written together
There is a second-order version of this that survives both fixes. Warm 10,000 keys in a deploy hook or a scheduled job and they are written within the same second, with the same TTL. They then expire within the same second, forever, in lockstep. You do not have one stampede, you have 10,000 of them synchronised.
Spread the expiry when you write:
Cache::put($key, $value, now()->addSeconds(3600 + random_int(0, 300)));
A few minutes of jitter across an hour-long TTL costs nothing in freshness and turns a cliff into a slope. The same reasoning applies to scheduled refreshes: a job that rebuilds every key at 0 * * * * has rebuilt the cliff you just removed.
What to change
Go through your remember() calls and put each into one of three buckets.
Cheap to recompute — under about 100ms, no fan-out to other services — leave it alone. The stampede costs less than the coordination.
Expensive and read constantly: move it to flexible(). Pick the fresh boundary from how stale the data may acceptably be, and the stale boundary from how long you are willing to keep serving the old value when the refresh fails.
Expensive and read intermittently, or where stale is genuinely unacceptable: wrap it in a lock, set the lock duration above the work's worst case, and handle LockTimeoutException with an explicit decision rather than a 500.
Then check how your keys are written. If anything warms many keys at once, add jitter to the TTL today — it is one line and it is the difference between a smooth curve and a cliff you fall off every hour on the hour.