Tech Verse Logo
Enable dark mode
Preventing Laravel Cache Stampedes with Atomic Locks

Preventing Laravel Cache Stampedes with Atomic Locks

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

Your dashboard runs an analytics query that takes 800ms to calculate. You cache it for an hour using Cache::remember(). Everything runs fast at 15ms per request until minute 60 hit. The cache key expires. Right at that millisecond, 300 incoming HTTP requests hit your PHP workers simultaneously. Every worker checks Redis, receives null, and executes that 800ms query against PostgreSQL.

Your database CPU spikes to 100%, connection pools max out, and web requests queue up until Nginx throws 504 Gateway Timeouts. This failure mode is a cache stampede, or dog-piling. Fixing it requires controlling how workers handle missing keys.

The Anatomy of a Stampede in Laravel

Standard cache helpers like Cache::remember() aren't safe for high-concurrency environments when the underlying callback is slow. The default logic looks like this:

  1. Fetch key from Redis.
  2. If key exists, return payload.
  3. If key misses, execute closure.
  4. Write closure output back to Redis and return payload.

The issue sits in step three. The time window between cache invalidation and step four writing the result back is open to every incoming request. If 200 workers enter step three during that 800ms window, you execute the same expensive query 200 times in parallel.

Single-Flight Execution with Cache::lock()

You can prevent dog-piling by enforcing single-flight execution. Only one worker gets to run the expensive query; all other concurrent workers block and wait for that worker to finish, then pull the newly cached payload directly from Redis.

Laravel 12 provides atomic locks through Cache::lock(), backed by Redis or Memcached drivers. Using block() on a lock instructs PHP workers to wait until the lock releases or times out before proceeding.

use Illuminate\Support\Facades\Cache; ... public function getMonthlyReport(): array { $cacheKey = 'analytics:monthly-report'; $lockKey = 'locks:analytics:monthly-report'; $report = Cache::get($cacheKey); if ($report !== null) { return $report; } return Cache::lock($lockKey, 10)->block(5, function () use ($cacheKey) { $data = Cache::get($cacheKey); if ($data !== null) { return $data; } $freshData = $this->runExpensiveQuery(); Cache::put($cacheKey, $freshData, now()->addHour()); return $freshData; }); }

Notice the double-check pattern inside the lock callback. Worker A misses the cache and grabs the lock. Worker B misses the cache milliseconds later and blocks, waiting on the lock. When Worker A finishes, puts data in Redis, and releases the lock, Worker B wakes up and gets its turn inside the callback. If Worker B didn't check Cache::get($cacheKey) again inside the lock, it would execute the query a second time anyway.

Preventing Synchronized Expiration with TTL Jitter

Atomic locks solve single-key stampedes, but they don't solve mass key expiration. Imagine you run a nightly cron job or bulk import that updates 5,000 product prices and caches each item for 24 hours. Exactly 24 hours later, all 5,000 cache keys expire within the same minute. Your database experiences 5,000 separate lock acquisitions and queries in seconds.

TTL jitter adds random variance to key expiration times. Instead of setting every item to expire in exactly 86,400 seconds, add a randomized delta.

public function cacheProduct(Product $product): void { $baseTtl = 86400; $jitter = random_int(-1800, 1800); $effectiveTtl = $baseTtl + $jitter; Cache::put("product:{$product->id}", $product->toArray(), $effectiveTtl); }

Spreading key deaths across a 60-minute window smooths out CPU and database usage spikes. The overall traffic profile flattens into a predictable line instead of sharp hourly cliffs.

Serving Stale Data While Revalidating

Blocking requests on an atomic lock prevents database meltdowns, but it forces waiting users to sit through an 800ms delay. If response speed matters more than fresh data, serve stale content instantly while updating the cache in the background.

Laravel 12 supports stale-while-revalidate out of the box with Cache::flexible(). Under the hood, this method wraps the payload in a container with two lifetimes: a fresh TTL and a stale TTL.

use Illuminate\Support\Facades\Cache; public function getDashboardMetrics(): array { return Cache::flexible( 'metrics:dashboard', [300, 600], function () { return $this->calculateMetrics(); } ); }

Here is how the [300, 600] array operates:

  • 0 to 300 seconds: Data is completely fresh. Returned immediately without running code.
  • 300 to 600 seconds: Data is stale. The current request gets the stale payload immediately (0ms delay), and Laravel dispatches a deferred background job to run the callback and write fresh data to Redis.
  • Past 600 seconds: Data is expired. The next worker falls back to fetching fresh data synchronously.

Writing a Custom Stale-While-Revalidate Engine

If you need control over queue dispatching or want custom logging when revalidation fails, write your own stale-while-revalidate wrapper using Redis hashes and background jobs.

namespace App\Services; use Illuminate\Support\Facades\Cache; use App\Jobs\RevalidateCacheJob; class AsyncCache { public static function remember(string $key, int $freshSeconds, int $staleSeconds, callable $callback): mixed { $payload = Cache::get("data:{$key}"); $meta = Cache::get("meta:{$key}"); if ($payload !== null) { if ($meta && now()->timestamp > $meta['expires_at']) { if (Cache::lock("locks:revalidate:{$key}", 10)->get()) { dispatch(new RevalidateCacheJob($key, $freshSeconds, $staleSeconds, $callback)); } } return $payload; } $fresh = $callback(); static::put($key, $fresh, $freshSeconds, $staleSeconds); return $fresh; } public static function put(string $key, mixed $data, int $freshSeconds, int $staleSeconds): void { Cache::put("data:{$key}", $data, $staleSeconds); Cache::put("meta:{$key}", [ 'expires_at' => now()->addSeconds($freshSeconds)->timestamp, ], $staleSeconds); } }

In this custom pattern, when data transitions into the stale window, Cache::lock()->get() attempts a non-blocking lock acquisition. If successful, it dispatches a background job and releases the main request thread instantly. Subsequent incoming requests during revalidation see that the lock is held, skip queueing duplicate jobs, and keep serving the existing stale cache entry until the worker completes.

Production Traps to Avoid

1. Misconfiguring PhpRedis vs Predis Blocking

When using block() with PhpRedis (the standard C extension for PHP 8.3), ensure your Redis connection timeout in config/database.php isn't smaller than your blocking wait duration. If your lock blocks for 5 seconds but your Redis read timeout is set to 2 seconds, PhpRedis throws a RedisException: read error on connection instead of returning lock failure.

2. Missing Lock Releases in Exception Handlers

If your closure throws an unhandled exception inside standard manual locks, explicit release() calls might be bypassed. Always pass callbacks to block() or wrap manual lock code inside try...finally blocks:

$lock = Cache::lock('reports', 10); if ($lock->get()) { try { $data = $this->runExpensiveQuery(); Cache::put('reports', $data, 3600); } finally { $lock->release(); } }

Using Cache::flexible() or atomic locks with TTL jitter keeps your database load constant, eliminating catastrophic latency spikes during unexpected cache misses.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Preventing Laravel Cache Stampedes with Atomic Locks

    Preventing Laravel Cache Stampedes with Atomic Locks

    Fast Laravel Database Testing Without In-Memory SQLite

    Fast Laravel Database Testing Without In-Memory SQLite

    Testing Laravel APIs with Pest: A Practical Guide

    Testing Laravel APIs with Pest: A Practical Guide

    Detecting and Fixing N+1 Queries in Laravel 12

    Detecting and Fixing N+1 Queries in Laravel 12

    Model Caching Patterns in Laravel 12 for Production

    Model Caching Patterns in Laravel 12 for Production

    Ensuring Secure URLs in Laravel Applications

    Ensuring Secure URLs in Laravel Applications

    Simple Feature Flags in Laravel with Laravel Toggle

    Simple Feature Flags in Laravel with Laravel Toggle

    Laravel WhatsApp: A New Package That Combines the Cloud API and whatsapp-web.js in One Library

    Laravel WhatsApp: A New Package That Combines the Cloud API and whatsapp-web.js in One Library

    Laravel diffForHumans() Guide: Display Human-Readable Time Like a Pro

    Laravel diffForHumans() Guide: Display Human-Readable Time Like a Pro

    Handling Large Datasets with Pagination and Cursors in Laravel MongoDB: Offset vs Cursor Pagination

    Handling Large Datasets with Pagination and Cursors in Laravel MongoDB: Offset vs Cursor Pagination