Cache stampede on a hot key

Caching and Redis Mid-level Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Pick the fix

A dashboard query takes 2 s and its cache key is read 500 times a second. Slightly stale data is fine. Best protection?

Options
Hint 1 of 1

Is stale data acceptable here?

Choose an answer, then open Show answer below to check it.

Fifty requests arrive over 600 ms just after a hot key expires and the database needs 250 ms. Without protection the first three slots all miss: 30 database reads. Single-flight makes 1 read while others wait; stale-while-revalidate makes 1 read while everyone gets the old value.

Run on Redis 7

Text version
No protection: 30 DB reads
SlotCacheRequestsDB reads
0–100 ms✕ Expired1010
100–200 ms✕ Expired1010
200–300 ms✕ Expired1010
300–400 ms✓ Fresh100
400–500 ms✓ Fresh50
500–600 ms✓ Fresh50
Single-flight: 1 DB reads
SlotCacheRequestsDB reads
0–100 ms↻ Refreshing101
100–200 ms↻ Refreshing100
200–300 ms↻ Refreshing100
300–400 ms✓ Fresh100
400–500 ms✓ Fresh50
500–600 ms✓ Fresh50
Stale-while-revalidate: 1 DB reads
SlotCacheRequestsDB reads
0–100 ms~ Stale101
100–200 ms~ Stale100
200–300 ms~ Stale100
300–400 ms✓ Fresh100
400–500 ms✓ Fresh50
500–600 ms✓ Fresh50
Show answer

Answer: A Stale-while-revalidate: serve the old value while one request refreshes in the background

Correct. No request waits and only one hits the database.

A stampede happens when many requests miss at the same moment and all recompute. Serving the stale value while a single request refreshes keeps latency flat and the database sees one 2-second query.

Why not the others?
  • B Increase the TTL to a day. Fewer stampedes, still one big one per day, and stale data for longer.
  • C Add read replicas. Spreads the stampede; doesn't stop it.
  • D Remove the TTL. Data never refreshes.

30-second answer

When a hot key expires, every request arriving before someone refills it misses and goes to the database at once: a stampede. Two fixes. Single-flight: only the request that wins a short lock reloads, and the others wait briefly for the new value. Stale-while-revalidate: keep serving the expired value while one background refresh runs, so nobody waits. In our Redis run, 50 requests over 600 ms made 30 database reads with no protection and 1 with either fix.

Practise saying it · 35 s. No recording, just a timer

Explain cache stampedes.

Say your answer out loud, then check which of these points you covered:

  • Hot key expires
  • Concurrent misses hit the DB
  • Single-flight lock with expiry
  • Stale-while-revalidate
  • Early refresh

Deep dive

Probabilistic early refresh: each request refreshes with a small probability that grows as expiry approaches, so one usually refreshes early.

The lock needs an expiry (SET NX PX) in case the holder dies.

Waiting requests need a timeout and a fallback.

Stampedes also happen on cold starts and after flushing the cache; warm critical keys first.

Fifty requests, three strategies, on Redis Ran on Python 3.13.5 · mypy --strict
Python 3.13
import threading
import time
from collections.abc import Callable

import redis

r = redis.Redis(decode_responses=True)
DB_SECONDS = 0.25
ARRIVALS = [10, 10, 10, 10, 5, 5]  # requests per 100 ms slot
db_reads = 0
lock = threading.Lock()


def load_from_db() -> str:
    global db_reads
    with lock:
        db_reads += 1
    time.sleep(DB_SECONDS)
    return "report-v2"


def no_protection() -> None:
    if r.get("report") is None:
        r.set("report", load_from_db(), ex=60)


def single_flight() -> None:
    while r.get("report") is None:
        if r.set("report:lock", "1", nx=True, px=5000):
            if r.get("report") is None:  # re-check under the lock
                r.set("report", load_from_db(), ex=60)
            r.delete("report:lock")
        else:
            time.sleep(0.02)


def refresh() -> None:
    r.set("report", load_from_db())
    r.set("report:fresh", "1", ex=60)
    r.delete("report:lock")


def stale_while_revalidate() -> None:
    value = r.get("report")  # stale copy is still there
    stale = r.get("report:fresh") is None
    if stale and r.set("report:lock", "1", nx=True, px=5000):
        threading.Thread(target=refresh).start()
    assert value is not None


def run(name: str, handler: Callable[[], None], stale: bool) -> None:
    global db_reads
    r.flushall()
    db_reads = 0
    if stale:
        r.set("report", "report-v1")
    threads = []
    for slot, n in enumerate(ARRIVALS):
        for _ in range(n):
            t = threading.Timer(slot * 0.1, handler)
            threads.append(t)
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    time.sleep(DB_SECONDS + 0.1)
    print(f"{name}: {db_reads}")


run("No protection", no_protection, False)
run("Single-flight", single_flight, False)
run("Stale-while-revalidate", stale_while_revalidate, True)

Common mistakes

  • Letting every miss rebuild the value.
  • A lock with no expiry.
  • Fixing it with longer TTLs only (the stampede still happens, just less often).

Senior take & follow-ups

Name the cause, then single-flight and stale-while-revalidate, with the lock's expiry.

Follow-up questions

Think of your answer first, then open the hint.

  1. Single-flight or stale-while-revalidate?

    Show hint

    SWR when slightly stale data is fine; single-flight when it isn't.

  2. What is singleflight in Go?

    Show hint

    An in-process version: concurrent callers for one key share one call.

Suggest a fix if something here is wrong or out of date.