Skip to content
OpenCloseEsc
Blog

6 min read

Stopping a cache stampede in Laravel

What happens when a hot cache key expires on a busy site, and two small fixes: an atomic lock so one request rebuilds, and serving stale data so nobody waits.

Caching feels like a solved problem until the day it isn't. You wrap a slow query in Cache::remember, the page gets fast, everyone's happy. Then one day the database spikes for a few seconds for no obvious reason, and it keeps happening at regular intervals. If you've ever seen that, there's a good chance you hit a cache stampede.

This is a short write-up of what it is and how I deal with it in Laravel. The fix isn't complicated, but it's the kind of thing that's easy to miss until it bites you.

The problem

Say you have a stats widget on your homepage. The numbers come from a heavy query, so you cache it for 10 minutes:

public function getStats(): array
{
    return Cache::remember('homepage.stats', now()->addMinutes(10), function () {
        return $this->expensiveQuery(); // takes ~2 seconds
    });
}

This works fine most of the time. One request runs the query, the rest read from cache for the next 10 minutes.

The trouble starts the moment that key expires. remember is really just "get the value, and if it's missing, run the callback and store it." So picture what happens on a busy site at the exact second the key expires:

  • Request A comes in, cache is empty, starts the 2-second query.
  • Request B comes in 50ms later, cache is still empty (A hasn't finished), starts the same query.
  • Requests C through Z do the same.

Now you have dozens of identical heavy queries running at once instead of one. The cache didn't protect you — it just delayed all the load into a single moment and made it worse. That's the stampede.

The fix: let one request rebuild, make the rest wait

The idea is simple: when the cache is empty, only one request should be allowed to rebuild it. Everyone else waits for that one to finish, then reads the fresh value.

Laravel has atomic locks built in for exactly this:

public function getStats(): array
{
    $key = 'homepage.stats';
 
    $value = Cache::get($key);
    if ($value !== null) {
        return $value;
    }
 
    // Cache is empty. Try to take the lock and rebuild.
    // block(5) waits up to 5 seconds for the lock to become free.
    return Cache::lock($key . ':lock', 10)->block(5, function () use ($key) {
        // We got the lock. But maybe another request already rebuilt
        // the cache while we were waiting, so check again first.
        $value = Cache::get($key);
        if ($value !== null) {
            return $value;
        }
 
        $value = $this->expensiveQuery();
        Cache::put($key, $value, now()->addMinutes(10));
 
        return $value;
    });
}

Now only one request runs the query. The others sit on block(5), wait a moment, and by the time they get in, the value is already there. One query instead of fifty.

A small thing to know: if block can't get the lock within the time you gave it, it throws a LockTimeoutException. On a 2-second query and a 5-second wait that's unlikely, but if your rebuild is slow you'll want to either raise the wait or catch it and return something sensible.

Atomic locks need a cache driver that supports them. Redis and Memcached both do, and so do the database and file drivers. Most projects are on Redis already, so usually there's nothing extra to set up.

A nicer version: serve slightly old data instead of waiting

The lock fix is good, but it still has one rough edge. The requests that don't win the lock have to wait for the rebuild. If your query takes 2 seconds, those users stare at a loading page for 2 seconds.

For something like a stats widget, nobody cares if the numbers are 30 seconds out of date. So a better trade is: keep serving the old value while one request quietly refreshes it in the background. Nobody waits.

The trick is to store two things — the data, and our own "this is getting stale" timestamp — and give the cache a much longer real lifetime than the freshness window:

public function getStats(): array
{
    $key = 'homepage.stats';
    $cached = Cache::get($key);
 
    if ($cached) {
        // Still fresh? Just return it.
        if ($cached['fresh_until'] > now()->timestamp) {
            return $cached['data'];
        }
 
        // Stale. Try to grab the lock to refresh.
        // If we get it, we refresh. If we don't, someone else is already
        // on it, so we just hand back the old data and move on.
        $lock = Cache::lock($key . ':refresh', 10);
        if ($lock->get()) {
            try {
                return $this->rebuildStats($key);
            } finally {
                $lock->release();
            }
        }
 
        return $cached['data']; // serve stale, no waiting
    }
 
    // Truly empty (first load, or it got evicted). Here we do block,
    // because we have nothing old to fall back on.
    return Cache::lock($key . ':refresh', 10)->block(5, function () use ($key) {
        $fresh = Cache::get($key);
        return $fresh ? $fresh['data'] : $this->rebuildStats($key);
    });
}
 
private function rebuildStats(string $key): array
{
    $data = $this->expensiveQuery();
 
    Cache::put($key, [
        'data'        => $data,
        'fresh_until' => now()->addMinutes(10)->timestamp, // soft expiry
    ], now()->addHour()); // hard expiry, much longer
 
    return $data;
}

Now the flow is:

  • Fresh data → return it.
  • Slightly stale data → one request refreshes it, everyone else gets the old copy instantly.
  • Nothing at all → fall back to waiting, since there's no old copy to serve.

The "hard" lifetime (one hour) just needs to be comfortably longer than the "fresh" window (10 minutes) so there's always an old value sitting around to serve while the refresh happens.

When is this worth it?

Not every cache needs this. If a key is cheap to rebuild, or the page barely gets traffic, plain remember is completely fine and you shouldn't add machinery you don't need.

It starts to matter when both things are true: the value is expensive to build, and the key is hot enough that lots of requests can miss at the same instant. Homepage data, expensive dashboards, anything shared across many users at once. That's where one expired key can briefly take your database down with it.

The good news is the fix is small. A lock so only one request rebuilds, and optionally a bit of stale-serving so nobody has to wait for it. Two ideas, and your cache stops being the thing that occasionally hurts you.