All posts
RedisAugust 14, 20268 min read

Redis Cache Stampede Prevention at High Throughput

How to stop a Redis cache stampede from taking down your database: TTL jitter, distributed locks, XFetch early recomputation, and request coalescing.

F
Fepiq Team
Fepiq

Search for "redis cache stampede prevention" and you'll find plenty of posts explaining what a stampede is. Few of them tell you which fix to reach for first, how to size a lock timeout, or what the XFetch probability curve actually looks like in production. This guide answers that directly: use a short-TTL distributed lock for low-traffic keys, switch to probabilistic early recomputation (XFetch) once a key is doing more than a few requests per second, and add request coalescing in your application tier if you're running multiple app instances. Below, we walk through each technique with the numbers you need to tune it for a real SaaS workload.

What a cache stampede actually costs you

A cache stampede, also called a thundering herd, happens when a popular Redis key expires or is evicted and dozens or thousands of concurrent requests all miss the cache at once. Every one of those requests falls through to the database to recompute the same value. On a low-traffic key that's a non-event. On a key backing your pricing page, your dashboard summary widget, or a shared feature-flag payload, it's a synchronized spike of duplicate, expensive queries hitting your primary at the exact same millisecond.

We've traced production incidents at client SaaS platforms back to exactly this pattern: a single Redis key with a flat 300-second TTL, sitting in front of a multi-table aggregation query, expiring at the same wall-clock second on every app server. The database saw a burst of 40-60 identical queries in under 200ms, connection pools maxed out, and unrelated requests started timing out. The fix wasn't more Redis memory or a bigger database instance — it was stampede protection at the cache layer.

Why this gets worse as you scale, not better

Stampede risk doesn't scale linearly with traffic — it scales with concurrency on a single key, which grows faster than overall request volume. Doubling your traffic doesn't just double the number of requests hitting an expired key; if your key space is concentrated (a handful of hot tenant dashboards, a shared config object, a trending product page), the herd on that one key can grow much faster. This is the same hot-key dynamic we covered in our guide to fixing Redis hot-key latency spikes, and stampedes are the write-side twin of that problem: instead of one key absorbing too much read traffic steadily, it absorbs a burst of write-through traffic all at once.

TechniqueBest forImplementation effortStampede coverage
TTL jitterLow-to-medium QPS keys, first line of defenseLow — one line at set timePartial: spreads expirations, doesn't stop concurrent misses
Distributed lock (SET NX PX)Medium QPS, one writer should winLow-medium — needs retry/backoff logicStrong: only one request recomputes
Probabilistic early recomputation (XFetch)High QPS hot keysMedium — needs a delta/beta formula per keyStrong: refreshes before expiry, no miss at all
Request coalescing / singleflightMultiple app instances, expensive upstream callsMedium — in-process plus cross-instance coordinationStrong within a process; needs a lock across instances
Negative cachingKeys with frequent not-found lookupsLowPrevents a different stampede: repeated misses on absent keys

TTL jitter: the cheap first fix

If you're setting every cache entry to expire in exactly 300 seconds, every key set in the same batch expires in the same batch. Add randomized jitter to the TTL so expirations spread out over time instead of clustering: TTL = base_ttl + random(-jitter, +jitter), for example a 300-second base with a plus-or-minus 30-second spread. This alone won't stop a stampede on a single very hot key, because concurrent requests can still all miss inside that jitter window, but it removes the synchronized-batch failure mode entirely and it costs you one line of code. We recommend it as the default for every cache write, even ones that also get lock or XFetch protection.

Distributed locking with SET NX PX

For keys where you want exactly one process to regenerate the value while everyone else waits, use Redis's atomic conditional set: SET key value NX PX 5000. The NX flag means the key is only set if it doesn't already exist, and PX gives the lock a millisecond expiry so a crashed worker can't hold it forever. Never split this into a separate EXISTS check and EXPIRE call — that introduces a race window where two processes can both believe they hold the lock.

  • Winner: the request whose SET NX PX succeeds recomputes the value, writes it to cache, then deletes the lock key.
  • Losers: requests that fail to acquire the lock should poll the cache on a short interval (50-100ms) or serve slightly stale data if you keep the old value around with a short grace TTL.
  • Lock TTL sizing: set it to comfortably cover your worst-case recomputation time, not your average. If your query normally takes 80ms but can spike to 2s under load, a 5s lock TTL gives headroom without risking a long stall if the winning process dies mid-computation.
  • Always release the lock with a value check (a Lua script comparing the stored token before DEL) so you never delete a lock acquired by a different process after your own expired.

Probabilistic early recomputation for your hottest keys

Locking works well until a key gets hot enough that the lock itself becomes a bottleneck — every request queues behind the same mutex. At that point the better approach is XFetch, the algorithm behind Facebook's cache client and popularized by Redis Labs' Vattani, Chierichetti, and Lowenstein paper on optimal probabilistic cache refresh. The idea: store the delta (how long recomputation took last time) alongside the cached value, and give every read a small, increasing probability of proactively refreshing the value before it actually expires, based on how close it is to its TTL.

The formula: refresh now if (current_time - (delta * beta * ln(random()))) >= expiry_time, where beta is a tuning constant (1.0 is a reasonable start; raise it to refresh earlier and more conservatively). Because the probability grows smoothly as the key approaches expiry, exactly one or a small handful of requests trigger the recompute well before the crowd would ever see a cache miss — the other 99.9% of requests keep reading the still-valid cached value. This is the technique we reach for on dashboard aggregates and pricing-tier lookups doing more than roughly 20-30 requests per second on a single key, where a lock would otherwise serialize a meaningful chunk of traffic.

Request coalescing across app instances

A distributed lock stops duplicate work across your whole fleet, but within a single app instance you can go further: coalesce concurrent requests for the same key into one in-flight call. In Node.js, keep an in-memory Map of key to in-flight Promise — if a second request for the same key arrives while the first is still resolving, hand it the same Promise instead of issuing a second Redis or database call. Go's singleflight package and similar libraries in other ecosystems do this natively. In a Laravel or PHP-FPM deployment, each request is its own process, so in-process coalescing doesn't apply the same way — lean on the Redis-level lock (SET NX PX) as your primary coalescing mechanism instead, since it works across processes and hosts by design.

Combine both when it matters: in-process coalescing for cost-free deduplication of the several requests that landed in the same event-loop tick, and the distributed lock as the cross-instance backstop. That two-layer approach is what we implement for clients running Node.js API gateways in front of shared Redis, where a single trending resource can otherwise receive dozens of near-simultaneous requests across a handful of container replicas.

Don't forget negative caching

A related but distinct stampede shows up when a key genuinely doesn't exist yet — a new user's dashboard before their first data lands, a product ID that was mistyped. Without protection, every one of those lookups falls through to the database on every request, forever. Cache the "not found" result too, with a short TTL (10-30 seconds is usually enough), so repeated misses on the same absent key don't recreate the exact same load pattern you just fixed for existing keys.

The goal isn't to make every cache miss impossible — it's to make sure a single expiring key can never turn into a synchronized burst of duplicate work against your database.
Fepiq engineering notes on Redis reliability

Putting it together for a SaaS API

In practice, most of our client platforms end up with a layered default: TTL jitter on every cache write with no exceptions, a SET NX PX lock as the baseline stampede guard on anything backed by a non-trivial query, and XFetch promoted in for the handful of keys that monitoring shows are genuinely hot — usually fewer than a dozen keys even on a busy platform. That's also the right order to implement in: jitter first because it's nearly free, locking second because it's a well-understood pattern your team can review quickly, and XFetch last, reserved for the keys where the data actually justifies the added complexity. Pair this with the eviction-policy and hot-key monitoring practices from our other Redis guides and you've covered the load-bearing failure modes that actually take SaaS platforms down under traffic spikes.

Frequently asked questions

What's the difference between a cache stampede and a hot key problem?+

A hot key problem is sustained, uneven read traffic on one key that overwhelms a single Redis shard. A cache stampede is a burst: that key's value expires or is evicted and many requests miss the cache at the same instant, all falling through to the database simultaneously. Hot keys make stampedes worse because more concurrent requests are waiting on the same key when it expires.

Is TTL jitter alone enough to prevent a stampede?+

For low-to-moderate traffic keys, often yes, because jitter spreads expirations across a wider window so far fewer requests land on an empty cache at once. For genuinely hot keys doing dozens of requests per second, jitter reduces the blast radius but doesn't eliminate concurrent misses, so pair it with a lock or probabilistic early recomputation.

How do I size the lock timeout for SET NX PX?+

Set it to your worst-case recomputation time plus a safety margin, not your average case. Measure the slowest realistic execution of the query or computation behind the cache, and give the lock enough headroom that it won't expire mid-computation and let a second process start duplicate work.

Does XFetch work with Redis Cluster?+

Yes. XFetch is purely application-side logic layered on top of a normal GET and SET, so it works with any Redis deployment topology, including Redis Cluster and managed services like AWS ElastiCache. The only requirement is storing the recomputation delta alongside the cached value, typically as a small companion field or a second key.

Should I use request coalescing instead of a Redis lock?+

Use both, at different layers. In-process request coalescing (an in-memory Promise map in Node.js, or singleflight in Go) is free deduplication within a single instance, but it doesn't coordinate across your other app servers. A Redis-level lock is what stops duplicate work across your whole fleet, so it should be your primary defense whenever you run more than one instance.

For more on keeping Redis stable under real production load, see our deep dive on fixing Redis hot-key latency spikes at scale.

Read the hot-keys guide

Running a high-throughput SaaS platform and want a Redis architecture review? Fepiq's team designs and audits caching layers for startups and growing platforms worldwide.

Talk to Fepiq

Get new posts in your inbox

Occasional, no-fluff notes on shipping modern software — startups, automation, Laravel, Shopify and more. No spam, unsubscribe anytime.

Keep reading

Related posts

All posts

Let's build something

Ready to ship your next product with Fepiq?

Book a free discovery call. We'll listen, ask sharp questions, and send you a proposal within 3 business days.