DEV Community

Cover image for Multi-Tier Caching & Stampede Protection with Redis/Valkey: Singleflight Locks & SWR
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

Multi-Tier Caching & Stampede Protection with Redis/Valkey: Singleflight Locks & SWR

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

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.

Three illuminated compartments with stacked glowing blue cubes, representing layered L1, L2 and database cache tiers

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();
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. Slow rebuilds under load. The database slows down because of the stampede, which lengthens the window, which lets more requests in.
  3. 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;
    });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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

  1. List your top 20 keys by hit rate (redis-cli --hotkeys with an LFU eviction policy, or app-level metrics) and their rebuild times.
  2. Any key with rebuild time over ~200 ms and high traffic gets SWR or a singleflight lock.
  3. Add 5-15% TTL jitter to every write so keys do not expire together after a deploy or flush.
  4. Use compare-and-delete lock release, with lock TTL longer than the worst rebuild.
  5. Handle lock timeouts gracefully with a stale or default value, never a 500.
  6. Cache negative results (empty lists, "not found") for a short TTL so missing data does not hit the database on every request.
  7. Set maxmemory and an eviction policy (allkeys-lfu is a good default for pure cache instances) and alert on evicted_keys.
  8. 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

Top comments (0)