Picture a failure mode I design against on every high-traffic Laravel build: Redis is fine, but a single popular key, say the product listing payload for an e-commerce homepage, expires at 9:00 PM during a campaign push. Within a few hundred milliseconds, hundreds of PHP-FPM workers miss the cache at the same moment, all run the same heavy aggregate query, and MySQL hits its connection limit. The cache is working exactly as written. It just has no answer for "what happens when everyone misses at once?"
At Loop Origin we build and run Laravel and Node.js SaaS and e-commerce backends where a cold key during peak traffic is a real outage risk. This post covers the layered cache design we use: an in-process L1, a shared Redis or Valkey L2, singleflight locks so only one worker rebuilds a key, stale-while-revalidate so users never wait for the rebuild, and TTL jitter so keys do not all expire together.
📚 Series Roadmap — Production AI & SaaS Systems Engineering
- Part 1: Building Production MCP Servers & Tool-Calling AI Agents: Sandboxing, Schemas & Approval Gates
- Part 2: Zero-Downtime Database Migrations in Laravel & Node.js SaaS: Expand-Contract & Lock Timeouts
- Part 3 (This Post): Multi-Tier Caching & Stampede Protection with Redis/Valkey: Singleflight Locks & SWR
- Part 4: SaaS Usage-Based Billing & Metering Architecture: Event Ingestion, Rollups & Stripe Invoicing
- Part 5: Production Observability on a Budget: OpenTelemetry Traces, Structured Logs & Tail Sampling
- Part 6: LLM Gateway & FinOps Architecture for B2B SaaS: Semantic Caching, Routing & Tenant Quotas
How do you prevent a cache stampede with Redis or Valkey in Laravel?
Direct answer: Make sure only one worker rebuilds an expired key and everyone else gets a value immediately. Use a singleflight lock (SET key token NX PX) around the rebuild, serve stale data while a refresh runs in the background (stale-while-revalidate, Cache::flexible() in Laravel), add random jitter to TTLs, and put a short-lived in-process L1 cache in front of Redis.
Why the naive cache-aside pattern breaks under load
Almost every codebase starts with this:
// BEFORE: classic cache-aside. Correct, and dangerous at peak traffic.
$products = Cache::remember('home:products:v3', 300, function () {
return Product::query()
->with(['variants', 'images'])
->where('is_featured', true)
->orderByDesc('sales_rank')
->limit(48)
->get();
});
Cache::remember checks the key, and on a miss runs the closure and stores the result. Between the miss and the write there is a window equal to the rebuild time. Every request arriving in that window also misses and also runs the query. With 1.8 seconds of rebuild time and 700 requests per second on the homepage, that is over 1,200 duplicate queries for one key.
Three things make it worse in practice:
- Synchronized expiry. A deploy or cache flush writes hundreds of keys with the same TTL, so they all expire in the same second later on.
- Slow rebuilds under load. The database slows down because of the stampede, which lengthens the window, which lets more requests in.
- Retries. Timeouts at the load balancer trigger client retries, adding more misses. I wrote about how retries amplify outages in Resilient ERP & CRM API Integrations.
The multi-tier layout we use
| Tier | Technology | Typical TTL | Scope | Latency | What It Protects Against | Main Risk |
|---|---|---|---|---|---|---|
| L1 in-process | APCu, Octane cache, or an LRU map in Node.js | 1-10 s | One worker/process | ~microseconds | Redis network round trips on very hot keys | Stale per-node copies after a write |
| L2 shared | Redis or Valkey (single primary + replica, or cluster) | 1-60 min, jittered | All app servers | ~0.3-1 ms in the same region | Database load and slow aggregates | Stampede on expiry, memory eviction |
| L3 source | PostgreSQL/MySQL, search index, external API | n/a | System of record | 5 ms to seconds | Being the truth | Connection exhaustion during stampedes |
| Edge (optional) | CDN with stale-while-revalidate headers |
10-120 s | Public anonymous pages | Network edge | Origin traffic entirely | Caching personalized data by mistake |
Valkey is the open-source fork of Redis and speaks the same protocol and commands, so everything below works with either. On Laravel, the redis cache driver talks to both without changes.
Pattern 1: Singleflight lock around the rebuild
Only one worker should rebuild a key. The others either wait briefly for the result or get a stale value. In Laravel, atomic locks give you this on Redis:
function rememberSingleflight(string $key, int $ttl, Closure $rebuild): mixed
{
$value = Cache::get($key);
if ($value !== null) {
return $value;
}
// Only one process holds this lock; others wait up to 3s for it.
return Cache::lock("lock:{$key}", 10)->block(3, function () use ($key, $ttl, $rebuild) {
// Double-check: another worker may have filled it while we waited.
$value = Cache::get($key);
if ($value !== null) {
return $value;
}
$value = $rebuild();
Cache::put($key, $value, $ttl + random_int(0, (int) ($ttl * 0.1))); // jitter
return $value;
});
}
Two details that matter in production: the lock TTL (10 s here) must be longer than the slowest realistic rebuild, or two workers can end up rebuilding; and block() throws LockTimeoutException if it waits too long, so catch it and return a fallback (stale value or a lightweight default) instead of a 500.
Under the hood this is the standard Redis pattern:
SET lock:home:products:v3 <random-token> NX PX 10000 # acquire only if absent
-- release only if we still own it (Lua, atomic):
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end
Never release with a plain DEL. If your rebuild ran past the lock TTL and another worker took the lock, a plain DEL removes their lock.
Pattern 2: Stale-while-revalidate so nobody waits
Locks stop duplicate rebuilds, but the requests waiting on the lock still feel the latency. SWR fixes that: keep serving the old value for a grace period while one worker refreshes it.
Laravel 11 added Cache::flexible() for exactly this:
// AFTER: fresh for 5 minutes, then stale-but-served for up to 10 more while refreshing once
$products = Cache::flexible('home:products:v3', [300, 900], function () {
return Product::query()
->with(['variants', 'images'])
->where('is_featured', true)
->orderByDesc('sales_rank')
->limit(48)
->get();
});
Within the first 300 seconds the value is fresh. Between 300 and 900 seconds, callers get the stale value immediately and Laravel schedules a single refresh after the response is sent, guarded by a lock. After 900 seconds it behaves like a normal miss. In practice, moving the hottest keys (homepage, category and "bestsellers" payloads) to this pattern is what turns a campaign-night database spike into a flat line, because only one worker ever recomputes each key.
On older Laravel versions, store ['value' => ..., 'fresh_until' => time() + $ttl] with a longer Redis TTL and dispatch a queued refresh job (under a lock) when fresh_until has passed. If you run queues for this, the Horizon and Supervisor setup from Running Laravel Queues in Production applies directly.
Pattern 3: Singleflight inside one Node.js process
In Node.js, a single process handles many concurrent requests, so you can collapse duplicate in-flight rebuilds in memory before Redis is even involved:
const inflight = new Map<string, Promise<unknown>>();
export async function singleflight<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = inflight.get(key);
if (existing) return existing as Promise<T>;
const p = fn().finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
// usage: L1 LRU -> singleflight -> Redis -> DB
const cached = l1.get(key) ?? await singleflight(key, async () => {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const fresh = await loadFromDb();
await redis.set(key, JSON.stringify(fresh), { EX: 600 + Math.floor(Math.random() * 60) });
return fresh;
});
l1.set(key, cached, { ttl: 5_000 });
With 20 Node.js pods, this cuts a stampede from thousands of concurrent rebuilds to at most 20. Add the Redis lock from Pattern 1 if even 20 is too many.
Pattern 4: L1 invalidation without surprises
The L1 tier is where teams get burned by stale data after an admin edits a product. Rules we follow:
- Keep L1 TTLs very short (1-10 s) and only for data where a few seconds of staleness is fine.
- Never put per-user or permission-sensitive data in L1 unless the key includes the user or tenant ID.
- On writes, delete or overwrite the L2 key, then broadcast an invalidation over Redis pub/sub so each node drops its L1 copy. The short TTL is the safety net if a message is missed.
- Version your keys (
home:products:v3) so a deploy that changes the payload shape does not read old serialized objects.
The 8-step stampede-readiness checklist
-
List your top 20 keys by hit rate (
redis-cli --hotkeyswith an LFU eviction policy, or app-level metrics) and their rebuild times. - Any key with rebuild time over ~200 ms and high traffic gets SWR or a singleflight lock.
- Add 5-15% TTL jitter to every write so keys do not expire together after a deploy or flush.
- Use compare-and-delete lock release, with lock TTL longer than the worst rebuild.
- Handle lock timeouts gracefully with a stale or default value, never a 500.
- Cache negative results (empty lists, "not found") for a short TTL so missing data does not hit the database on every request.
-
Set
maxmemoryand an eviction policy (allkeys-lfuis a good default for pure cache instances) and alert onevicted_keys. - Load-test the cold path. Flush the cache in staging under realistic load and watch DB connections and p99. That is the test that matches the real incident.
Frequently asked questions
Is Valkey a drop-in replacement for Redis in Laravel?
For caching, locks, queues and sessions, yes in our experience. It uses the same protocol and commands, and the Laravel redis driver with phpredis or Predis connects to it the same way. Test any module-specific features you rely on before switching.
What is the difference between a cache stampede and the thundering herd problem?
They are closely related. A cache stampede is the specific case where many requests miss the same expired key and rebuild it at once. Thundering herd is the broader pattern of many waiters waking up for one resource. Singleflight and SWR address both in the caching layer.
Should I use probabilistic early expiration instead of locks?
It is a good option for very hot keys: each request has a small, increasing chance of refreshing the key before it expires, so one early refresh usually happens without any lock. In practice I find SWR simpler for teams to reason about, and I keep locks as the backstop.
Does Cache::flexible() work with every cache driver?
It relies on atomic locks and deferred work after the response, so use a driver that supports locks, such as Redis/Valkey, Memcached, database or DynamoDB. The file and array drivers are fine for local development, not for a multi-server fleet.
How big should my Redis or Valkey instance be for caching?
Size from real data: sum the serialized size of your hot keys, add headroom for traffic growth, and keep usage under about 70% of maxmemory. Watch used_memory, eviction counts and hit ratio rather than guessing. Our server setup & maintenance service does this sizing as part of capacity planning.
Where should caching live in an e-commerce stack?
Cache catalog, category and pricing-display data aggressively with SWR, but never cache stock reservations or cart totals as the source of truth. Those need database locks, as covered in How to Prevent Double Booking: Locks, Holds and Expiry.
Next in this series, Part 4 covers usage-based billing and metering architecture. If your Laravel or Node.js app falls over when the cache goes cold, our Laravel development team and e-commerce development team fix exactly this kind of problem, or you can get in touch directly.
Follow & Connect
- Website: Loop Origin
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)

Top comments (0)