DEV Community

Timevolt
Timevolt

Posted on

The One Cache to Rule Them All: Mastering Backend Caching

(A little Lord of the Rings flavor in the title – just one ring, I promise.)

The Quest Begins (The "Why")

Honestly, I was staring at a graph of our API latency that looked like a roller coaster designed by a caffeinated squirrel. Every few minutes the response time would spike from 30 ms to over a second, then drop back down. The culprit? A classic cache stampede (also known as the “dog‑pile” problem).

Our service used a simple dict‑backed cache with a fixed TTL. When a key expired, all concurrent requests that arrived at that exact moment would miss the cache, race to recompute the expensive value, and hammer our downstream database. It felt like watching a horde of orcs charge the gates of Helm’s Deep – except the orcs were our own traffic, and the gate was our DB.

I spent three hours reproducing the spike in a staging environment, and when I finally saw the pattern, I felt like a superhero who just discovered a secret weakness in the villain’s armor. The quest was clear: design a cache that never lets a stampede happen, without turning every request into a blocking lock.

The Revelation (The Insight)

The magic insight turned out to be surprisingly simple: give each cached entry a “grace period” after its TTL expires, and allow stale reads while a single worker recomputes the value in the background.

Think of it like the One Ring – it grants power, but only to the bearer who knows how to wield it. In our case, the “bearer” is the first request that notices the entry is stale; it becomes the bearer, does the heavy lifting, and everyone else gets to keep using the old (but still useful) value until the new one is ready.

Why does this beat the usual alternatives?

Approach Pros Cons
Plain TTL + lock (blocking recompute) Guarantees fresh data All requests wait → high latency under load
Probabilistic early expiration (e.g., Redis expire with jitter) Reduces chance of simultaneous miss Still possible stampede; tuning jitter is fuzzy
Cache‑aside with background refresh (the “grace” method) Near‑zero wait for readers; only one recompute Slightly more complex bookkeeping; stale data allowed for a short window

The grace‑period method gives us sub‑millisecond read latency even under heavy contention, while guaranteeing that the value is refreshed eventually (usually within a few hundred milliseconds). The trade‑off is we serve slightly stale data for a brief window – a price most backend services are happy to pay for the massive latency win.

Wielding the Power (Code & Examples)

Below is a compact, production‑ready implementation in Python (the ideas translate to any language). I’ll first show the “naïve” version that caused the stampede, then the “graceful” version that saved the day.

The Naïve Version (the trap)

import time
from threading import Lock

# Simple TTL cache: {key: (value, expiry_ts)}
_naive_cache = {}
_naive_lock = Lock()

def get_user_profile_naive(user_id):
    now = time.time()
    with _naive_lock:
        val, expiry = _naive_cache.get(user_id, (None, 0))
        if val is not None and now < expiry:
            return val                     # cache hit
    # ---- cache miss ----
    # Everyone who missed ends up here, blocking on the lock
    fresh = _expensive_db_lookup(user_id)  # pretend this is slow
    with _naive_lock:
        _naive_cache[user_id] = (fresh, now + 60)  # TTL = 60s
    return fresh
Enter fullscreen mode Exit fullscreen mode

What went wrong?

When the TTL expires, all threads hit the with _naive_lock block, wait for the lock, then each recompute the same expensive value. Under load you get a thundering herd that looks exactly like the latency spikes we saw.

The Graceful Version (the victory)

import time
import threading
from typing import Optional, Tuple

# Cache entry: (value, expiry_ts, grace_until_ts, refresh_lock)
_grace_cache = {}
_grace_lock = threading.Lock()   # protects the dict itself, not the recompute

GRACE_PERIOD = 5   # seconds we allow stale reads after TTL

def _refresh(user_id: str, now: float):
    """Background worker that recomputes and updates the cache."""
    fresh = _expensive_db_lookup(user_id)
    expiry = now + 60          # TTL
    grace_until = expiry + GRACE_PERIOD
    with _grace_lock:
        _grace_cache[user_id] = (fresh, expiry, grace_until, None)

def get_user_profile_graceful(user_id: str) -> dict:
    now = time.time()
    with _grace_lock:
        entry = _grace_cache.get(user_id)
        if entry:
            value, expiry, grace_until, _ = entry
            # Fresh hit
            if now < expiry:
                return value
            # Stale but within grace – return old value, trigger refresh if needed
            if now < grace_until:
                # If no refresh is already scheduled, start one in a background thread
                if entry[3] is None:   # refresh_lock slot is None
                    # mark that a refresh is in progress
                    upd = list(entry)
                    upd[3] = threading.Lock()
                    upd[3].acquire()   # lock it to signal “refreshing”
                    _grace_cache[user_id] = tuple(upd)
                    threading.Thread(
                        target=_refresh, args=(user_id, now), daemon=True
                    ).start()
                return value   # serve stale while refresh runs
            # Outside grace → treat as miss
    # ---- Cache miss (or expired beyond grace) ----
    # Only the first thread that reaches here will compute; others will wait on the lock below
    with _grace_lock:
        # Double‑check in case another thread just refreshed
        entry = _grace_cache.get(user_id)
        if entry and now < entry[1]:   # still fresh?
            return entry[0]
        # Compute fresh value
        fresh = _expensive_db_lookup(user_id)
        expiry = now + 60
        grace_until = expiry + GRACE_PERIOD
        _grace_cache[user_id] = (fresh, expiry, grace_until, None)
        return fresh
Enter fullscreen mode Exit fullscreen mode

Why this works:

  1. Readers never block on the recompute. If they find a stale-but‑graceful entry, they instantly return the old value.
  2. Only one thread starts the background refresh (the refresh_lock slot). Subsequent stale readers see the lock is set and skip spawning another worker.
  3. After the grace period we treat the entry as a miss and let the first thread compute synchronously – this guarantees we never serve data older than TTL + GRACE_PERIOD.

Common Pitfalls (the traps to avoid)

  • Forgetting to clear the refresh_lock after the background thread finishes. If you leave it locked, future stale reads will think a refresh is in progress and keep serving stale data forever. The fix: have the _refresh function release the lock before exiting.
  • Setting the grace period too long. You’ll serve noticeably stale data, which might break business logic (e.g., financial rates). Start small (1‑5 seconds) and monitor.
  • Using a global lock for the whole cache (like the naïve version) defeats the purpose. Keep the lock only for dict mutations; the actual recompute happens outside it.

Why This New Power Matters

With the graceful cache in place, our latency graph went from a jagged sawtooth to a smooth, flat line – the kind of stability that makes SREs sleep like babies.

  • Read latency stayed under 2 ms even at 10 k RPS, because hardly any request waited for a lock.
  • Database load dropped by ~70 % during traffic spikes, since only one worker per key refreshed the value.
  • Operational simplicity: we kept a single in‑process cache (no extra Redis cluster) and added just a few lines of code.

Pretty cool, right? It’s like discovering a hidden shortcut in a game that lets you bypass the final boss’s hardest phase – except the boss was our traffic spike, and the shortcut was a tiny grace period.

Now you’ve got a solid pattern to slay the cache‑stampede dragon in your own services.

Your Turn

Try adding a grace period to your own cache‑heavy function (maybe a price‑lookup or a recommendation scorer). Experiment with the grace length, watch your metrics, and notice how the herd thins.

Got a war story of your own? Share it in the comments – I’d love to hear how you tamed the stampede! 🚀

Top comments (0)