๐Ÿ—ƒ๏ธ Caching & Memoization

The fastest work is the work you never do. Under load, a cache turns a database from your bottleneck into a footnote โ€” right up until it doesn't.

The Cheapest Win Under Load

If the same input produces the same output, computing it twice is waste. At 10,000 req/sec, shaving a 50ms DB round-trip off 90% of requests via a cache is often the difference between one box and twenty.

Cache only what's safe to cache: results that are pure (deterministic, no side effects) or tolerant of staleness for some TTL. Caching a value that changes every request just adds overhead and a correctness bug.

functools.lru_cache / functools.cache

For pure functions, memoization is one decorator. lru_cache(maxsize=N) keeps the N most recently used results and evicts the least recently used; functools.cache (3.9+) is just lru_cache(maxsize=None) โ€” unbounded, so only use it when the key space is small and fixed.

from functools import lru_cache, cache

@lru_cache(maxsize=1024)          # bounded โ€” safe for a large/unbounded key space
def expensive(n: int) -> int:
    # pretend this is a heavy pure computation or a slow lookup
    return sum(i * i for i in range(n))

@cache                            # unbounded โ€” fine ONLY for small fixed key sets
def config_for(env: str) -> dict:
    return _load_config(env)      # e.g. "dev"/"staging"/"prod"

expensive(10_000)                 # computed
expensive(10_000)                 # instant โ€” served from cache

print(expensive.cache_info())     # CacheInfo(hits=1, misses=1, maxsize=1024, currsize=1)
expensive.cache_clear()           # wipe it (e.g. on config reload)
Watch the traps: arguments must be hashable (no lists/dicts as keys), the cache holds strong references so it can leak memory (prefer a bounded maxsize), and it's per interpreter โ€” see the multiprocessing caveat below. Under threading, lru_cache itself is thread-safe (it locks internally), but that lock does NOT stop a stampede โ€” read on.

A Simple TTL Cache

lru_cache has no notion of time โ€” entries live until evicted. When data goes stale after N seconds, you want a TTL cache: store (value, expiry) and treat expired entries as misses.

import time

class TTLCache:
    def __init__(self, ttl: float):
        self.ttl = ttl
        self._store: dict = {}     # key -> (value, expires_at)

    def get(self, key):
        hit = self._store.get(key)
        if hit is None:
            return None
        value, expires_at = hit
        if time.monotonic() >= expires_at:
            self._store.pop(key, None)   # lazily evict on read
            return None
        return value

    def set(self, key, value):
        self._store[key] = (value, time.monotonic() + self.ttl)
# In production, reach for cachetools โ€” it handles eviction + max size + TTL for you:
from cachetools import TTLCache
cache = TTLCache(maxsize=10_000, ttl=60)   # bounded AND time-limited
cache["user:42"] = profile                 # expires 60s after write

The Big High-Load Trap: Cache Stampede

Also called: thundering herd, dogpile, cache miss storm.

Scenario: A hot key is served from cache at 5,000 req/sec โ€” the DB sees ~0 load. The key's TTL expires. In the next few milliseconds, thousands of concurrent requests all check the cache, all miss, and all decide to recompute โ€” hammering the DB with 5,000 identical queries at once. The DB falls over, recompute takes even longer, and the cache stays empty because everyone's stuck waiting. A single expiry took down the whole service.
flowchart TB EXP["๐Ÿ”‘ Hot key expires"] --> FORK{"Thundering herd:
every request
misses at once"} FORK -->|no coordination| STAMPEDE["๐Ÿ’ฅ 5,000 identical
DB queries
โ†’ DB overload"] FORK -->|single-flight| ONE["1 request recomputes"] ONE --> SHARE["others wait,
share the result"] SHARE --> OK["โœ… DB sees 1 query"]
The fix is coordination: when the cache misses, only one caller should recompute; everyone else waits for that result. This is called single-flight (or request coalescing). You add a per-key lock and re-check the cache after acquiring it (double-checked).

In Threading โ€” a per-key lock, double-checked

Keep a dict of threading.Lock keyed by cache key. A thread that misses grabs the key's lock, re-checks the cache (someone may have filled it while it waited), and only then recomputes. The rest block on the lock and get the freshly-cached value for free.

import threading

class SingleFlightCache:
    def __init__(self, compute, ttl: float):
        self._compute = compute
        self._cache = TTLCache(ttl)              # from earlier
        self._locks: dict = {}
        self._locks_guard = threading.Lock()     # guards the dict of locks itself

    def _lock_for(self, key) -> threading.Lock:
        with self._locks_guard:
            lock = self._locks.get(key)
            if lock is None:
                lock = self._locks[key] = threading.Lock()
            return lock

    def get(self, key):
        value = self._cache.get(key)
        if value is not None:
            return value                          # fast path: hit, no locking

        with self._lock_for(key):                 # only contenders for THIS key serialize
            value = self._cache.get(key)          # double-check: filled while we waited?
            if value is not None:
                return value
            value = self._compute(key)            # exactly ONE thread runs this
            self._cache.set(key, value)
            return value
Per-key, not one global lock. A single lock would serialize all recomputes, even for unrelated keys โ€” you'd throttle yourself. Lock per key so a miss on user:42 never blocks a miss on user:99.

In Asyncio โ€” a single-flight coalescer

Same idea, but instead of a lock you cache the in-flight coroutine's Task. The first caller for a key creates the task; every subsequent caller awaits the same task and shares its result. No thread locks โ€” just structured sharing of one Future.

import asyncio

class AsyncSingleFlight:
    def __init__(self, compute, ttl: float):
        self._compute = compute                   # async callable
        self._cache = TTLCache(ttl)
        self._inflight: dict = {}                 # key -> asyncio.Task

    async def get(self, key):
        value = self._cache.get(key)
        if value is not None:
            return value                          # fast path: hit

        task = self._inflight.get(key)
        if task is None:
            # first caller for this key creates the ONE task everyone shares
            task = asyncio.ensure_future(self._fill(key))
            self._inflight[key] = task
        return await task                         # all concurrent callers await the same task

    async def _fill(self, key):
        try:
            value = await self._compute(key)      # runs exactly once per key
            self._cache.set(key, value)
            return value
        finally:
            self._inflight.pop(key, None)         # let the next miss start fresh
# 1,000 concurrent requests for the same cold key โ†’ ONE actual DB call:
sf = AsyncSingleFlight(compute=fetch_user_from_db, ttl=60)
results = await asyncio.gather(*(sf.get("user:42") for _ in range(1000)))
# all 1,000 resolve from a single fetch_user_from_db("user:42")
Alternatively, an asyncio.Lock per key mirrors the threading version exactly. The task-coalescing approach above is tidier in asyncio because a Task already is a shareable future โ€” no need for a separate lock plus double-check.

Multiprocessing & Multi-Instance โ€” local caches don't share

lru_cache and any in-process dict live in one interpreter's memory. With 4 worker processes (or 20 pods), you have 4 (or 20) independent caches: the hit rate drops, and a single-flight lock in process A does nothing to stop process B from also stampeding the DB.

# Shared cache across processes / machines: Redis (or memcached).
import redis, json

r = redis.Redis()

def get_user(user_id: str) -> dict:
    key = f"user:{user_id}"
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)                 # shared hit across ALL processes/pods
    value = fetch_user_from_db(user_id)
    r.set(key, json.dumps(value), ex=60)          # ex = TTL in seconds
    return value
Distributed single-flight: a per-process lock can't coordinate across boxes. Use a short-lived Redis lock (e.g. SET lock:key val NX EX 5) so exactly one pod recomputes a hot key while the others briefly serve stale or retry. This is the multi-instance version of the double-checked lock above โ€” the same reason distributed rate limiting needs Redis: the coordination point must be outside any single process.

Two More Patterns Worth Knowing

# Stale-while-revalidate sketch: keep value + a "soft" and "hard" expiry.
def get_swr(key):
    value, soft, hard = _cache.get3(key)          # (value, soft_expiry, hard_expiry)
    now = time.monotonic()
    if value is not None and now < hard:
        if now >= soft:
            schedule_background_refresh(key)       # refresh async; don't block the caller
        return value                               # serve immediately, possibly stale
    return recompute_and_cache(key)                # only truly-expired hits pay the cost

Key Takeaways

ModelCache + stampede fixScope
Threadinglru_cache/TTL + per-key threading.Lock, double-checkedShared within one process
AsyncioTTL cache + single-flight Task coalescer (or per-key asyncio.Lock)Shared within one event loop
Multiprocessing / multi-instanceRedis/memcached + short Redis lock for single-flightShared across all processes/pods
Checklist: โ‘  memoize pure functions with a bounded lru_cache, check cache_info() โ‘ก TTL-cache staleness-tolerant data โ‘ข coalesce misses (single-flight) so one caller recomputes โ‘ฃ per-key locks, never one global โ‘ค negative-cache misses/errors โ‘ฅ share across processes with Redis โ€” local caches don't coordinate โ‘ฆ stale-while-revalidate to kill the stampede window entirely.