Caching Strategies Lesson 3 of 6 ⏱️ 4 min read

Cache Invalidation & Cache Stampedes

Why cache invalidation is hard, TTL vs event-based invalidation, and how to prevent thundering-herd cache stampedes.

Updated Oct 30, 2026

"There are only two hard things in computer science…"

"…cache invalidation and naming things." The joke is popular because it's true. A cache is a copy, and keeping copies in sync with the source of truth across many servers is genuinely hard.

Invalidation strategies

Strategy How it works Trade-off
TTL expiry Entries expire after a fixed time Simple; data can be stale for up to the TTL
Explicit delete on write Code deletes the key when data changes Fresh, but every write path must remember to do it
Event-based Data-change events (e.g. from a message queue or change data capture) trigger invalidation Decoupled and reliable; more infrastructure
Versioned keys Include a version in the key: course:1:v7; bump the version on change Old keys simply stop being read, then expire

Production systems usually combine them: explicit or event-based invalidation for freshness, plus a TTL as a safety net.

The race condition you need to know

Cache-aside has a classic race between a reader and a writer:

Plaintext
1. Reader: cache miss for course:1
2. Reader: reads OLD value from the database
3. Writer: updates the database to NEW value
4. Writer: deletes cache key course:1
5. Reader: writes the OLD value into the cache   ← stale until TTL

Mitigations:

  • Short TTLs for data that changes often, so the window is bounded.
  • Delayed double-delete — delete the key again shortly after the write.
  • Versioned values — store a version with each value and refuse to cache a value older than the latest known version.
  • For critical reads, skip the cache entirely.

Cache stampede (thundering herd)

A popular key expires. In the next instant, hundreds of requests miss at once — and all of them query the database to rebuild the same value. The database collapses exactly when traffic is highest.

Fix 1: single-flight (request coalescing)

Only one request rebuilds a missing key; the others wait for its result.

Python
import threading
import time

db_calls = 0
cache: dict[str, str] = {}
locks: dict[str, threading.Lock] = {}
locks_guard = threading.Lock()

def load_from_db(key: str) -> str:
    global db_calls
    db_calls += 1
    time.sleep(0.2)                          # a slow query
    return f"value-for-{key}"

def get(key: str) -> str:
    if key in cache:
        return cache[key]
    with locks_guard:                        # one lock per key
        lock = locks.setdefault(key, threading.Lock())
    with lock:
        if key not in cache:                 # double-check after acquiring the lock
            cache[key] = load_from_db(key)
    return cache[key]

threads = [threading.Thread(target=get, args=("trending",)) for _ in range(50)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print("50 concurrent requests → database calls:", db_calls)

Output:

Plaintext
50 concurrent requests → database calls: 1

Across multiple servers, the same idea uses a distributed lock — for example Redis SET key value NX PX 5000 — so only one server rebuilds the value.

Fix 2: jittered TTLs

If thousands of keys are cached at the same moment with the same TTL, they also expire together. Add randomness:

Python
import random

def ttl_with_jitter(base_seconds: int, jitter: float = 0.1) -> int:
    return int(base_seconds * random.uniform(1 - jitter, 1 + jitter))

Fix 3: refresh before expiry

Refresh hot keys in the background shortly before they expire (sometimes called refresh-ahead or stale-while-revalidate): users keep getting the slightly old value while a single worker fetches the new one. Probabilistic early expiration does this without a scheduler — each read has a small chance of refreshing early, growing as expiry approaches.

Fix 4: serve stale on error

If the database is down, returning a slightly stale cached value is often better than an error. Keep a longer "stale" copy alongside the fresh one.

Other cache failure modes

  • Cache penetration — repeated requests for keys that don't exist always miss. Cache "not found" results briefly, or use a Bloom filter to reject impossible keys.
  • Hot keys — one key receives a huge share of traffic (a viral post). Replicate it across several keys or keep a small in-process copy.
  • Cache avalanche — the whole cache restarts empty. Warm critical keys on startup and protect the database with rate limits.

Common mistakes

  • Relying only on TTLs for data users just edited — they refresh the page and see their old data.
  • Identical TTLs for everything, causing synchronised expiry.
  • Global locks that serialise all cache rebuilds instead of per-key locks.
  • No monitoring — track hit ratio, evictions and rebuild latency.

Key takeaways

  • Combine explicit or event-based invalidation with TTLs as a safety net.
  • Know the read/write race in cache-aside and bound it with short TTLs or versions.
  • Prevent stampedes with single-flight locking, jittered TTLs and refresh-ahead.
  • Handle penetration, hot keys and cold starts before they become outages.
Finished this lesson?
Tick it off to track your Caching Strategies progress.
Next lesson