Caching in Laravel without the stampede
Published July 4, 2026 · by Majd Ghithan, written with the help of AI
We cached the homepage's "trending products" query for five minutes. It was our slowest query — about 900ms, a big aggregation across orders. Caching it took the homepage from sluggish to instant. Ship it, celebrate, move on.
Then, every five minutes, exactly on the minute, the database CPU spiked to 100% for a second or two. Errors, timeouts, then back to calm. It took me an embarrassingly long time to connect it to the cache TTL. The cache wasn't protecting the database. It was scheduling a database attack every five minutes.
That's a cache stampede, and if you cache anything expensive under real traffic, you will meet it.
Why the stampede happens
A cached value has a TTL. When it expires, the next request that asks for it gets a miss and rebuilds it. In a demo, that's one request. Under load, it's not.
Picture 500 requests per second hitting that homepage. The cache expires at 12:05:00. The request at 12:05:00.001 misses and starts the 900ms rebuild. But so does the request at .002, and .050, and every single request for the next 900 milliseconds — because none of them see a cached value yet, the first rebuild hasn't finished. So instead of one expensive query, you fire 450 identical expensive queries at once, all computing the exact same thing, all hammering the database that the cache was supposed to shield.
The cache didn't reduce your database load. It concentrated it into one-second spikes and hid it from you the rest of the time. This is also called a dogpile — everyone piles onto the rebuild at once.
The fix: one rebuild, everyone else waits or serves stale
The naive rebuild and the safe rebuild look almost identical in code. The difference is a lock.
$data = Cache::remember('trending', 300, function () {
return $this->expensiveAggregation();
});
// 450 concurrent misses
// = 450 rebuilds
// = DB on fire
$lock = Cache::lock('trending:rebuild', 10);
if ($lock->get()) {
try {
$data = Cache::remember('trending', 300,
fn () => $this->expensiveAggregation());
} finally {
$lock->release();
}
} else {
// someone else is rebuilding — serve last known value
$data = Cache::get('trending:last', []);
}
Cache::lock() gives you an atomic lock across all your workers (backed by Redis or Memcached). Only one request wins the lock and rebuilds. The other 449 don't queue up behind an expensive query — they take the fast path and serve the last good value. One database query instead of 450.
Stale-while-revalidate: nobody ever waits
The lock still means one unlucky request pays the 900ms rebuild cost. You can do better: serve the stale value to everyone, including the one that triggers the refresh, and rebuild in the background.
Laravel's Cache::flexible() does exactly this. You give it two durations — a fresh window and a stale window:
$data = Cache::flexible('trending', [300, 600], function () {
return $this->expensiveAggregation();
});
For the first 300 seconds the value is fresh and served instantly. Between 300 and 600 seconds it's stale but still served instantly — and the request that noticed it's stale kicks off a background refresh (via a deferred callback) instead of blocking. After 600 seconds it's fully expired and behaves like a normal miss. In practice almost every request gets an instant response and the rebuild happens off to the side. This is the pattern I reach for first now.
Staggered TTLs stop synchronized expiry
Even with locks, if you cache 50 keys that all expire at exactly the same second — say you warmed them all in one loop — they'll all stampede together. Add a little jitter so they don't expire in lockstep:
$ttl = 300 + random_int(0, 60); // 5 min, spread across a 60s window
Cache::put($key, $value, $ttl);
Now the expiries smear across a minute instead of detonating on the same tick. It's one line and it turns a synchronized spike into a flat line.
Invalidate with tags, not a sledgehammer
When the underlying data changes, you often need to drop several related keys at once. Cache::flush() nukes everything, including sessions and unrelated caches. Tags let you invalidate a group surgically:
Cache::tags(['products'])->remember('trending', 300, fn () => ...);
Cache::tags(['products'])->remember('featured', 300, fn () => ...);
// a product changed — drop only product caches
Cache::tags(['products'])->flush();
Note tags need a taggable store — Redis or Memcached, not the file or database driver. If you're on file in production for a cache this important, that's the first thing to fix.
The mistake isn't caching. It's assuming a TTL is the whole story. A plain remember() with a TTL is a cache that works perfectly right up until enough people use it at once — which is exactly the moment you added the cache for. Protect the rebuild, and the spike flattens into the smooth line you thought you shipped in the first place.
