
September 12, 2026
12 min read
By Kokil Thapa | Last reviewed: September 2026
A hot cache key expires and fifty PHP workers miss at once. Each worker runs the same heavy query against MySQL. Response times spike, connection pools fill, and checkout or booking flows start failing. That cascade is a cache stampede, also called a thundering herd. You need deliberate patterns to prevent cache stampede and thundering herd before traffic spikes, deploys, or TTL expiry windows hit production. This guide covers the mechanics, Laravel and Redis implementations, and the trade-offs I use on real client systems.
What causes a cache stampede and thundering herd?
A cache stampede starts with a shared miss. Many concurrent requests read the same key after expiry or eviction. None find a value. Each request falls through to the origin — a database query, external API call, or expensive PHP computation.
The thundering herd name fits because every process runs the same work at the same moment. On a Laravel booking app I maintain, a popular trek availability key expiring during peak season once triggered hundreds of identical joins. CPU climbed first. Then MySQL threads maxed out. Finally users saw 504 errors from the reverse proxy.
Three triggers show up repeatedly in production:
- TTL expiry on hot keys — product catalogues, exchange rates, homepage aggregates, or legal fee tables that every page load touches.
- Mass invalidation — flushing a tag or prefix after a deploy or content publish, as described in Laravel cache tags with Redis vs Memcached.
- Cold start — Redis restart, cache layer failure, or a new app node with an empty local APCu layer after deploy.
The problem scales with fan-out. A homepage that calls twelve cached fragments makes one miss feel like twelve. Nested Eloquent queries without eager loading multiply the pain further. Stampede prevention belongs in architecture reviews alongside speed optimization and capacity planning — not as an afterthought when Grafana alerts fire at midnight.
How do you prevent cache stampede with Laravel and Redis?
Laravel 12 and 13 ship a built-in cache lock API backed by Redis 8.10, Memcached 1.6.x, or the database. The pattern is single-flight regeneration: one worker acquires a lock, rebuilds the value, and writes it back. Everyone else waits briefly or reads stale data.
Single-flight lock with Cache::lock()
This is my default for expensive keys on production Laravel apps running PHP 8.3 or 8.5:
<?php
use Illuminate\Support\Facades\Cache;
use Illuminate\Contracts\Cache\LockTimeoutException;
function featuredProducts(): array
{
$key = 'catalog:featured:v3';
return Cache::remember($key, 3600, function () use ($key) {
$lock = Cache::lock("lock:{$key}", 10);
try {
$lock->block(5);
return Product::query()
->featured()
->with(['category', 'media'])
->limit(24)
->get()
->all();
} finally {
optional($lock)->release();
}
});
}
The outer remember() still handles the happy path. The inner lock covers the miss path. Only one process runs the query while peers block up to five seconds. After the value lands in Redis, blocked workers read the warm key instead of hitting MySQL.
Official reference: the Laravel cache lock documentation at laravel.com/docs/12.x/cache describes block(), get(), and owner tokens. Redis lock behaviour follows the vendor guidance on distributed locks at redis.io/docs.
Manual remember-with-lock wrapper
For keys you invalidate manually, a reusable helper keeps logic consistent across controllers and jobs:
<?php
function cacheSingleFlight(string $key, int $ttlSeconds, callable $resolver): mixed
{
$cached = Cache::get($key);
if ($cached !== null) {
return $cached;
}
$lock = Cache::lock("lock:{$key}", 15);
try {
if ($lock->block(8)) {
$cached = Cache::get($key);
if ($cached !== null) {
return $cached;
}
$value = $resolver();
Cache::put($key, $value, $ttlSeconds);
return $value;
}
} finally {
optional($lock)->release();
}
throw new LockTimeoutException("Could not acquire cache lock for {$key}");
}
Always double-check the cache after acquiring the lock. Another worker may have finished while you waited. That second read is the difference between a mutex and wasted duplicate work.
Redis SET NX EX pattern without Laravel
On legacy PHP or Symfony 8.1 projects, the same idea maps to atomic Redis commands. Symfony's cache component patterns are covered in our Symfony cache with Redis and APCu article:
$lockKey = "lock:{$cacheKey}";
$acquired = $redis->set($lockKey, 1, ['NX', 'EX' => 10]);
if ($acquired) {
try {
$data = expensiveQuery();
$redis->setex($cacheKey, 3600, serialize($data));
} finally {
$redis->del($lockKey);
}
} else {
usleep(100_000);
$data = $redis->get($cacheKey);
}
Keep lock TTL short. A crashed worker should not block regeneration for minutes. Ten to fifteen seconds is typical for database-backed rebuilds.
What is probabilistic early expiration for cache warming?
Locks solve concurrent misses. Probabilistic early expiration (also called proactive TTL jitter) reduces synchronized expiry. Instead of every process discovering an expired key at second zero, some requests refresh the value slightly early based on random chance.
The XFetch / early expiration formula
A simplified PHP implementation for a key with known computation cost:
<?php
function cacheWithEarlyExpiration(
string $key,
int $ttl,
float $beta,
callable $resolver
): mixed {
$entry = Cache::get($key);
if ($entry === null) {
return refreshCache($key, $ttl, $resolver);
}
$value = $entry['data'];
$storedAt = $entry['stored_at'];
$delta = time() - $storedAt;
$randomFactor = -log(mt_rand() / mt_getrandmax());
if ($delta > ($ttl - $beta * $randomFactor)) {
return refreshCache($key, $ttl, $resolver);
}
return $value;
}
Higher $beta spreads refreshes across a wider window before hard expiry. For a one-hour TTL on exchange-rate data served to eCommerce checkout, I often use beta between 1.0 and 2.0. That spreads rebuilds across several minutes instead of one sharp cliff.
Stale-while-revalidate
HTTP caches popularized stale-while-revalidate. Application caches can mirror it. Store two timestamps: hard expiry and soft stale window. On read inside the stale window, return old data immediately and dispatch a queue job to refresh.
- Request hits key past soft TTL but before hard expiry.
- User receives stale payload with acceptable latency.
- Background job rebuilds and replaces the key.
- Next request gets fresh data without a miss storm.
On trek availability dashboards, stale-while-revalidate kept admin pages responsive during supplier API slowdowns. Data might be ninety seconds old. That beat a thirty-second page hang while every admin refreshed the same upstream feed.
Pair this pattern with Laravel queues and Horizon monitoring. The refresh job itself should still use a lock so ten queued workers do not stampede the origin.
Which cache stampede prevention strategy should you choose?
No single pattern wins every scenario. Pick based on consistency requirements, rebuild cost, and failure tolerance.
| Strategy | Best for | Pros | Cons |
|---|---|---|---|
| Cache lock / single-flight | Expensive DB aggregates, legal fee tables, catalogue facets | Strong deduplication; native Laravel support | Waiting requests add latency; lock misconfiguration causes timeouts |
| Probabilistic early expiration | High-read keys with predictable TTL (rates, config blobs) | Smooth load; no lock contention spikes | Occasional early rebuilds; tuning beta takes measurement |
| Stale-while-revalidate | Homepage modules, CMS blocks, non-critical pricing hints | Fast user response; origin protected | Serves slightly old data; needs job queue health |
| Pre-warming before expiry | Known cron boundaries (midnight rate updates, BS date rollovers) | Zero user-facing miss | Requires reliable scheduler; wasted work if key unused |
| Request coalescing at edge | Public JSON endpoints behind CDN | Shields origin entirely for anonymous traffic | Harder for authenticated or personalised responses |
For most Laravel production systems I combine lock-based single-flight on write with early expiration on read-heavy keys. WordPress object-cache setups follow the same logic — see WordPress object cache with Redis for plugin-level constraints.
Rate limiting complements caching for abuse scenarios but does not replace stampede control. Read API rate limiting and abuse prevention when public endpoints share cache keys across anonymous users.
How do you monitor and test for thundering herd problems in production?
Prevention without observability is guesswork. You need metrics that tie cache behaviour to database health.
Metrics worth alerting on
- Cache hit ratio per key prefix — sudden drops on
catalog:orrates:often precede stampedes. - Lock wait time — Laravel logs lock timeouts; track them as a first-class signal.
- MySQL slow query count grouped by normalized SQL hash — duplicate hashes during TTL windows confirm herd behaviour.
- PHP-FPM queue depth — workers stuck waiting on locks or DB show up here before HTTP errors climb.
Prometheus and Alertmanager patterns from alerting with Prometheus Alertmanager apply directly. Export Redis keyspace_misses and application-level counters from a custom middleware if needed.
Load-test the miss path deliberately
Happy-path load tests lie. They only hit warm caches. Before a product launch or Dashain sale on a Nepali eCommerce site, flush a single hot key under controlled load:
ab -n 2000 -c 100 https://staging.example.com/catalog/featured
Run the test twice: once with locks disabled (baseline) and once with your single-flight wrapper enabled. Compare p95 latency and database QPS. Log results in your JSON formatter pipeline or Grafana dashboard for regression tracking.
On Deployer 7 releases across sister legal-tech sites, I flush cache during low-traffic windows and pre-warm critical keys from an Artisan command scheduled five minutes before peak morning traffic. That command lives in the same deploy repo as the app — no manual Redis CLI steps after midnight deploys.
Common mistakes that reintroduce stampedes
Even teams that know the theory slip on implementation details:
- Lock inside remember without double-check — every waiter still queries after lock release if you skip the second
Cache::get(). - Same TTL on all keys — midnight cron plus identical TTL creates synchronized expiry. Jitter TTL by a random offset up to ten percent.
- Tag flush during peak — invalidating
productstag on WooCommerce 11.1 during checkout hours hurts more than a gradual key rotation. - Ignoring null caching — caching empty results for known-miss lookups stops repeated database hits for bogus IDs.
- Local APCu without coordination — each PHP-FPM worker holds its own copy. A miss on every worker still multiplies origin load unless Redis is the shared authority.
Redis data structure choices matter for hot keys. Large serialised blobs block the single-threaded Redis engine during reads. Consider hashes or smaller keyed chunks as outlined in Redis data structures beyond cache.
For enterprise workloads with strict SLAs, bake these patterns into code review checklists during enterprise application development. For ongoing tuning after launch, testing and optimization engagements should include a controlled stampede drill — not just Lighthouse scores.
Key Takeaways
- A cache stampede happens when many concurrent requests miss the same expired key and duplicate expensive origin work.
- Use Laravel
Cache::lock()with double-check after acquire to implement single-flight regeneration on hot keys. - Add probabilistic early expiration or stale-while-revalidate when slightly stale data is acceptable and locks alone feel too brittle.
- Jitter TTL values and avoid mass tag flushes during peak traffic to prevent synchronized expiry cliffs.
- Monitor cache hit ratio, lock timeouts, and duplicate SQL hashes — then load-test the miss path before high-traffic events.
- Combine Redis 8.10 as shared cache authority with queue-based background refresh for the best balance of freshness and stability.
People Also Ask
What is the difference between cache stampede and thundering herd?
They describe the same failure mode from different angles. Cache stampede emphasises many processes rushing the cache layer at expiry. Thundering herd emphasises many workers hitting the backend simultaneously. In Laravel and Redis architectures, both terms point to duplicate work that collapses under load.
Does Laravel Cache::remember prevent stampede automatically?
No. Standard Cache::remember() does not acquire a distributed lock on miss. Concurrent misses still execute the closure in parallel. Wrap the miss path with Cache::lock() or use a dedicated single-flight helper to deduplicate regeneration.
Can Cloudflare or a CDN stop cache stampede at the origin?
An edge cache absorbs anonymous traffic for cacheable responses. Authenticated Laravel pages, cart sessions, and personalised API payloads usually bypass CDN cache. Origin-side locks and early expiration remain necessary for dynamic application keys even when static assets sit behind Cloudflare.
How long should a cache lock TTL be?
Set lock TTL slightly above your worst-case rebuild time, typically ten to thirty seconds for database-backed keys. Too short risks two holders if rebuild exceeds TTL. Too long blocks recovery after a crashed worker until the key expires naturally.
Stop stampedes before your next traffic spike
Hot keys fail at predictable moments — TTL expiry, deploy flushes, and seasonal peaks. Patterns to prevent cache stampede and thundering herd cost little code but save hours of emergency firefighting. Start with locks on your three most expensive cached queries. Add TTL jitter this week. Schedule a staging miss-path load test before the next campaign.
If you want help auditing cache architecture on a Laravel, WordPress, or custom PHP platform, review our support and maintenance options or Linux system administration for Redis and PHP-FPM tuning. For greenfield work with caching built in from day one, see API development or browse the portfolio for production examples. Contact us to discuss your stack — a thirty-minute architecture review often surfaces one hot key waiting to expire at the worst possible minute.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

