Prevent Cache Stampedes in Django

Stop cache stampedes (dogpile effects) in Django when a popular cache key expires and hundreds of requests recompute it at once: use locks, stale-while-revalidate, jittered timeouts, background refresh, and probabilistic early expiration.

Problem statement

Your home page caches an expensive query for five minutes. Every five minutes, at peak traffic, response times spike and database CPU jumps to 100 percent. When the key expires, every request in flight sees a miss at the same moment and runs the same expensive query, so the database receives hundreds of identical heavy queries at once. This is a cache stampede, also called the dogpile or thundering herd problem.

How a stampede happensPopular keyexpiresMany requestsall missAll recomputesame heavy queryDB overloadslow pagesWithout coordination, every concurrent miss rebuilds the same value.
  1. Only let one process rebuild a key: take a short Redis lock (cache.add or django-redis lock) and let others wait or serve stale data.
  2. Serve stale while revalidating: store the value with a soft expiry and refresh it in the background.
  3. Add random jitter to timeouts so keys created together do not expire together.
  4. Refresh critical keys proactively with Celery beat before they expire.
  5. Use probabilistic early expiration for keys you cannot schedule.

Step-by-step solution

1. Confirm it is a stampede

Signs: latency and database CPU spikes at regular intervals matching a cache timeout, many identical slow queries in pg_stat_statements or the slow log at the same timestamps, and Redis showing a burst of misses for one key.

2. Rebuild under a lock

import time
from django.core.cache import cache

def get_with_lock(key, compute, timeout=300, lock_timeout=30, wait=0.1, max_wait=5):
    value = cache.get(key)
    if value is not None:
        return value
    lock_key = f"lock:{key}"
    if cache.add(lock_key, "1", lock_timeout):       # only one caller wins
        try:
            value = compute()
            cache.set(key, value, timeout)
            return value
        finally:
            cache.delete(lock_key)
    deadline = time.monotonic() + max_wait
    while time.monotonic() < deadline:              # others wait briefly
        time.sleep(wait)
        value = cache.get(key)
        if value is not None:
            return value
    return compute()                                 # fallback if the winner is slow

cache.add sets the key only if it does not exist, which works as a simple mutex across processes with Redis or Memcached. The lock timeout must exceed the computation time.

technique and trade-offEffectTrade-offLock on rebuildone rebuild at a timeothers waitStale-while-revalidateusers get old valuebrief stalenessTimeout jitterspreads expiriesno help for one keyBackground refreshnever expires in useneeds Celery beatEarly expirationrandom early rebuildslight extra workCombine a lock with stale data for the hottest keysdjango-deployment.com
Jitter alone is enough when many similar keys expire together.

3. Serve stale while revalidating

Store the value with its own soft expiry and keep the cache entry longer:

import time

def get_swr(key, compute, fresh_for=300, stale_for=3600):
    entry = cache.get(key)
    now = time.time()
    if entry and entry["expires"] > now:
        return entry["value"]
    if entry and cache.add(f"lock:{key}", "1", 60):
        refresh_cache_key.delay(key)                 # Celery task recomputes and sets
        return entry["value"]                        # serve stale meanwhile
    if entry:
        return entry["value"]
    value = compute()
    cache.set(key, {"value": value, "expires": now + fresh_for}, fresh_for + stale_for)
    return value

Users never wait for the rebuild once the key exists; only the very first request computes synchronously.

4. Add jitter and refresh proactively

import random
cache.set(key, value, timeout=300 + random.randint(0, 60))

For a handful of critical keys (home page data, navigation, pricing tables), schedule a Celery beat task that recomputes them every few minutes, with a timeout longer than the schedule. They then never expire under traffic.

5. Probabilistic early expiration

Each reader recomputes slightly early with a probability that grows as expiry approaches (the "XFetch" approach). With delta the time the computation takes and beta around 1, recompute when now - delta * beta * log(random()) >= expiry. This spreads rebuilds without locks and works well for keys with steady traffic.

what happens at expiryKey expirespeak trafficNo lock: 300 rebuildsDB saturatedLock: 1 rebuildothers wait or staleValue set againwithin a secondNormal latencyno spikeCoordination turns hundreds of rebuilds into onedjango-deployment.com
The spike size scales with traffic, so stampedes get worse as the site grows.

Explanation

A cache turns many reads into one computation only while the value exists. At the instant it expires, the cache provides no coordination: each process independently sees a miss and starts the same work. For cheap values that is harmless; for queries taking hundreds of milliseconds under high concurrency, it multiplies load exactly when traffic is highest. Locks coordinate rebuilds, stale serving removes waiting, and jitter or early refresh avoids synchronized expiry.

Edge cases / notes

  • Lock failures. If the lock holder crashes, the lock expires after lock_timeout; keep it short but longer than the computation.
  • django-redis locks. cache.lock("name", timeout=30) provides a Redis lock object with blocking options.
  • Per-user keys. Stampedes mainly affect shared keys; per-user caches rarely see concurrent misses.

Next steps

FAQ

What is a cache stampede?

Many concurrent requests missing the same expired key and recomputing it at once, overloading the database.

Does Django's cache framework prevent stampedes?

No. You add coordination with locks, stale serving, or proactive refresh.

Is cache.add a reliable lock?

It is atomic in Redis and Memcached, which makes it a simple lock. For stricter needs, use Redis locks with tokens.

Should every cache key use a lock?

No. Use locks for expensive, popular keys; jitter covers many similar keys cheaply.

Learning path · step 7 of 12

Making Django fast

Keep going