HTTP response caching — etags, last-modified headers
Concept
Cache stampede (also called "thundering herd" or "dog-pile effect") is a race condition where many requests simultaneously find a cache miss and all compute the expensive value at once — overwhelming the database with N identical queries at the same moment the cache expires.
When it happens: A popular cache key expires. 100 concurrent requests all find Cache::get() returning null. All 100 execute the callback in Cache::remember(). All 100 hit the database. Database load spikes to 100x.
Solution 1: Atomic lock in remember: Before computing, acquire a lock. Only one process computes; others wait or get stale data.
Solution 2: Cache::flexible() (Laravel 11+): Two TTLs — a "fresh" TTL and a "stale" TTL. Within fresh TTL: returns cached value. Between fresh and stale TTL: returns stale cached value AND recomputes in background. Never a total cache miss while there's stale data available.
Solution 3: Early expiry + background refresh: Before the TTL expires, schedule a refresh job. The value is always available; the job silently updates it.
Solution 4: Jitter: Add random variation to TTLs so not all keys expire simultaneously: $ttl = 3600 + rand(0, 600).
Solution 5: Probabilistic early expiry (XFetch): When accessing a cached value, probabilistically decide to refresh early based on how close to expiry the value is. More efficient than locks.
Code Example
<?php
use Illuminate\Support\Facades\Cache;
// Problem — naive remember() can stampede
// 100 requests hit simultaneously after cache expires:
$value = Cache::remember('popular-data', 3600, fn() => DB::table('big_table')->get());
// All 100 execute DB query simultaneously!
// Solution 1: Lock + remember pattern
function stampedeSafe(string $key, int $ttl, callable $callback): mixed
{
$value = Cache::get($key);
if ($value !== null) return $value;
// Only one process computes; others receive stale or wait
$lock = Cache::lock("computing:$key", 30);
if ($lock->get()) {
try {
$value = $callback();
Cache::put($key, $value, $ttl);
} finally {
$lock->release();
}
return $value;
}
// Lock is taken — wait briefly, return whatever was computed
sleep(1);
return Cache::get($key, $callback()); // fallback: compute without cache
}
$data = stampedeSafe('popular-data', 3600, fn() => DB::table('big_table')->get());
// Solution 2: Cache::flexible() — Laravel 11+
// Returns cached value + recomputes in background between fresh/stale TTLs
$value = Cache::flexible('popular-data', [300, 3600], function() {
return DB::table('big_table')->get();
});
// [300, 3600]: fresh for 5 min, stale-but-serve until 60 min, then compute again
// Solution 3: Jitter on TTL — desynchronize expiry times
$baseTTL = 3600;
$jitter = rand(0, 600); // add 0-10 minutes randomly
Cache::put('data-1', $value1, $baseTTL + $jitter);
Cache::put('data-2', $value2, $baseTTL + rand(0, 600)); // different expiry time
// Solution 4: Background refresh job
// When a user loads a page with a cached value, check if it's "stale enough"
// to schedule a background refresh (even if TTL hasn't expired)
$cachedAt = Cache::get('popular-data:cached-at');
if ($cachedAt && now()->diffInSeconds($cachedAt) > 2700) { // 45 min out of 60
if (!Cache::has('popular-data:refreshing')) {
Cache::put('popular-data:refreshing', true, 60);
RefreshPopularDataJob::dispatch()->onQueue('low');
}
}