Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Prevent Cache Stampede and Thundering Herd

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.
Cache Stampede Flow100 Requestssame hot keyCache MISSkey expired100 DB Queriesduplicate workSymptoms During Stampedep95 latency spikesDB connections max503 / 504 user errors
Cache stampede diagram: one expired key can fan out into identical database load across every concurrent request.

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.

Single-Flight Lock PatternWorker Agets lockWorker BwaitsWorker CwaitsRedis Lockone holderNX + TTLOne DB Queryrebuild valueCache WARMWorkers B and C read warm key — no stampede
Thundering herd prevention with a Redis mutex: one worker rebuilds while peers wait or read the refreshed value.

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.

  1. Request hits key past soft TTL but before hard expiry.
  2. User receives stale payload with acceptable latency.
  3. Background job rebuilds and replaces the key.
  4. 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.

StrategyBest forProsCons
Cache lock / single-flightExpensive DB aggregates, legal fee tables, catalogue facetsStrong deduplication; native Laravel supportWaiting requests add latency; lock misconfiguration causes timeouts
Probabilistic early expirationHigh-read keys with predictable TTL (rates, config blobs)Smooth load; no lock contention spikesOccasional early rebuilds; tuning beta takes measurement
Stale-while-revalidateHomepage modules, CMS blocks, non-critical pricing hintsFast user response; origin protectedServes slightly old data; needs job queue health
Pre-warming before expiryKnown cron boundaries (midnight rate updates, BS date rollovers)Zero user-facing missRequires reliable scheduler; wasted work if key unused
Request coalescing at edgePublic JSON endpoints behind CDNShields origin entirely for anonymous trafficHarder 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.

Stampede Strategy Decision TreeHot cache key expiring?rebuild costlyUse cache lockstale OKStale-while-revalidateAdd early expirationQueue background jobAlways monitor miss rate + p95 latencyalert before users notice
Decision tree to prevent cache stampede: match strategy to rebuild cost and freshness tolerance.

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: or rates: 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.

Detecting Thundering Herd in ProductionCache hit ratioTTL expiryDB query latency p95Correlated spike = stampedeadd lock + early expiry
Monitoring thundering herd: cache miss cliffs aligned with database latency spikes reveal stampede events.

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 products tag 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

Many concurrent requests miss the same expired cache key and all run identical expensive work against MySQL, an API, or PHP at once. Response times spike, connection pools fill, and checkout or booking flows fail.

Three triggers show up repeatedly in production. TTL expiry on hot keys such as product catalogues, exchange rates, or homepage aggregates forces every page load to miss at once. Mass invalidation after a deploy or content publish, including Laravel cache tag flushes, clears shared keys during traffic. Cold start from a Redis restart, cache layer failure, or a new app node with an empty local APCu layer leaves no warm value for concurrent workers to read.

No. Standard Cache::remember() does not acquire a distributed lock on miss, so concurrent misses still execute the closure in parallel.

Use single-flight regeneration on the miss path. Wrap expensive rebuild logic inside Cache::lock() with block(), run the query once, write the value back to Redis 8.10, and release the lock in a finally block. Peers that blocked briefly then read the warm key instead of hitting MySQL. Laravel 12 and 13 ship this lock API natively for Redis, Memcached 1.6.x, or database drivers. Always double-check Cache::get() after acquiring the lock because another worker may have finished while you waited.

Only one worker rebuilds a hot key while others wait or read stale data.

Also called proactive TTL jitter, it reduces synchronized expiry by refreshing some keys slightly early based on random chance rather than at a hard cliff. A simplified XFetch-style formula compares elapsed time since storage against TTL minus a beta-scaled random factor. Higher beta spreads rebuilds across a wider window before hard expiry. For a one-hour TTL on exchange-rate data in eCommerce checkout, beta between 1.0 and 2.0 often spreads rebuilds across several minutes instead of one sharp moment when every process discovers expiry at second zero.

Store two timestamps: a hard expiry and a soft stale window. When a read falls inside the stale window, return old data immediately to the user and dispatch a Laravel queue job to refresh the key in the background. The next request gets fresh data without a miss storm. On trek availability dashboards, this kept admin pages responsive during supplier API slowdowns when data might be ninety seconds old but beat a thirty-second page hang. Pair the pattern with queue health monitoring and a lock on the refresh job so ten queued workers do not stampede the origin.

Match strategy to rebuild cost and freshness tolerance. Cache locks suit expensive database aggregates and legal fee tables where strong deduplication matters. Probabilistic early expiration fits high-read keys with predictable TTL such as rates or config blobs. Stale-while-revalidate works for homepage modules and non-critical pricing hints where slightly old data is acceptable. Pre-warming before known cron boundaries such as midnight rate updates avoids user-facing misses. Request coalescing at the CDN edge shields anonymous JSON endpoints but not authenticated Laravel pages. Most production Laravel systems combine lock-based single-flight with early expiration on read-heavy keys.

Set lock TTL slightly above worst-case rebuild time, typically ten to thirty seconds for database-backed keys.

An edge cache absorbs anonymous traffic for cacheable responses and can coalesce requests at the edge for public JSON endpoints. Authenticated Laravel pages, cart sessions, and personalised API payloads usually bypass CDN cache entirely. Origin-side locks, probabilistic early expiration, and stale-while-revalidate remain necessary for dynamic application keys even when static assets sit behind Cloudflare. Rate limiting complements caching for abuse scenarios but does not replace stampede control on shared hot keys behind the CDN.

Track metrics that tie cache behaviour to database health. Alert on cache hit ratio drops per key prefix such as catalog: or rates:, Laravel lock timeout logs as first-class signals, MySQL slow query counts grouped by normalized SQL hash to spot duplicate queries during TTL windows, and PHP-FPM queue depth before HTTP errors climb. Export Redis keyspace_misses and application-level counters. In Grafana, cache miss cliffs aligned with database latency spikes reveal stampede events. Prevention without observability is guesswork because you cannot confirm herd behaviour from happy-path traffic alone.

Happy-path load tests only hit warm caches and lie about stampede risk. Before a product launch or Dashain sale, flush a single hot key under controlled load using a tool such as ab with high concurrency against staging. Run the test twice: once with locks disabled as baseline and once with your single-flight wrapper enabled. Compare p95 latency and database QPS. Log results in Grafana or your JSON formatter pipeline for regression tracking. On Deployer 7 releases, flush cache during low-traffic windows and pre-warm critical keys from a scheduled Artisan command five minutes before peak morning traffic.

Teams slip on implementation details even when they know the theory. Placing a lock inside remember without a double-check means every waiter still queries after lock release. Identical TTL on all keys creates synchronized midnight expiry cliffs; jitter TTL by a random offset up to ten percent instead. Tag flush during peak on WooCommerce 11.1 hurts checkout more than gradual key rotation. Ignoring null caching lets repeated hits for bogus IDs hammer the database. Local APCu without Redis coordination means each PHP-FPM worker holds its own copy and a miss on every worker still multiplies origin load.

On legacy PHP or Symfony 8.1 projects, map single-flight to atomic Redis commands. Set a lock key with SET using NX and EX options for a short TTL such as ten seconds. If acquired, run the expensive query, write the cache key with SETEX, and delete the lock in a finally block. If not acquired, sleep briefly with usleep and read the cache key another worker may have populated. Keep lock TTL short so a crashed worker does not block regeneration for minutes. This mirrors Laravel Cache::lock() behaviour without the framework wrapper.

Each PHP-FPM worker maintains its own APCu copy with no cross-process coordination. When a hot key expires or is absent, every concurrent worker on every app node misses independently and falls through to MySQL or an external API at the same moment. Redis 8.10 as the shared cache authority ensures one rebuild populates a value all workers read. Large serialised blobs on hot keys can still block Redis single-threaded reads, so consider hashes or smaller keyed chunks. For stampede prevention, treat Redis as the authoritative shared layer rather than relying on per-worker local caches alone.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: