---
name: "redis_caching_patterns"
description: "Design server-side caching with Redis: cache-aside, write-through, write-behind patterns, TTL selection with jitter, thundering-herd/stampede protection (lock with SET NX PX, probabilistic early refresh, request coalescing), and cache invalidation strategies. Use when adding caching to reduce database load, choosing TTLs, or fixing stampede and invalidation bugs. Covers when NOT to cache and graceful degradation when Redis is down."
metadata: { "includeInPrompt": true }
---

> Differs from `performance_budgets` because this is server-side data caching strategy (read/write patterns, TTLs, stampedes, invalidation) — not frontend page-load budgets; and from `webhook_handling` because it caches data you already own, rather than plumbing inbound HTTP callbacks with retry/idempotency.

## Purpose

Give agents a concrete, safe procedure for adding Redis caching to a backend: which pattern to use, how to pick TTLs, how to survive cache stampedes, how to invalidate, and when to skip caching entirely. Every Redis command here is real. Logic was executed against fakeredis 2.38.0 (SET NX PX, SET with EX, DEL, GET, TTL round trips all verified); the safe-release Lua script is the standard Redlock pattern (fakeredis lacks EVALSHA, so release logic was verified client-side).

## Workflow

### 1. Decide whether to cache at all (gate first)

Cache only if ALL of these hold:

- Read/write ratio is comfortably high (>10:1 is the usual floor; recompute cost justifies it).
- Brief staleness is acceptable (the data isn't correctness-critical — no balances, auth tokens, inventory counts).
- The dataset isn't tiny and stable enough that the DB is already fast enough (check the query plan first — a missing index is not a caching problem).
- Hot keys are bounded in number; caching the full long tail wastes memory and evicts the hot keys.

When any one fails: **don't cache**. Cache adds a second source of truth — a bug factory you don't need.

### 2. Pick the pattern

| Pattern | Flow | Wins when | Consistency |
|---|---|---|---|
| **Cache-aside (lazy)** — default | Read: check cache, on miss load DB and populate. Write: update DB, delete key. | Reads dominate, data tolerant of brief staleness. | Eventual; race window between DB write and DEL is tiny but real. |
| **Read-through** | App reads only from cache; cache library loads DB on miss. | Many app instances; you want miss-loading centralized. | Same as cache-aside. |
| **Write-through** | Write goes to cache AND DB synchronously. | Cache must never serve stale on write; writes are cheap. | Stronger on write path; adds write latency. |
| **Write-behind (write-back)** | Write goes to cache only; async worker flushes to DB later. | Extremely write-heavy, loss-tolerant data (counters, analytics). | Risky: data loss if cache dies before flush. Use only when loss is acceptable. |

**Cache-aside pseudocode (the default — use this unless you have a reason not to):**

```
def get_user(user_id):
    key = f"user:{user_id}"
    hit = redis.get(key)
    if hit is not None:
        return deserialize(hit)
    user = db.query("SELECT ... WHERE id = ?", user_id)   # miss
    redis.set(key, serialize(user), ex=ttl_with_jitter(600))  # SET key val EX 600
    return user

def update_user(user_id, fields):
    db.execute("UPDATE ... WHERE id = ?", user_id, fields)  # DB first
    redis.delete(f"user:{user_id}")                          # then invalidate
```

Note: invalidate by DELETE on write, never by SET on write — a concurrent stale read can overwrite the fresh write between the DB update and your SET (the classic write-race). Deleting lets the next read re-populate from the DB.

**Write-through pseudocode:**

```
def update_user_wt(user_id, fields):
    db.execute("UPDATE ...", user_id, fields)
    redis.set(f"user:{user_id}", serialize(fields), ex=BASE_TTL)
    # Both succeed or both roll back in one transaction unit; read path stays plain GET.
```

### 3. Pick TTLs

- Rule of thumb: **TTL ≈ data-volatility period × 0.5**, bounded by cost of recompute (expensive recompute → longer TTL) and cost of stale data (high stale cost → shorter TTL).
- Per-key TTLs: don't use one global TTL. `user:profile` = 10 min, `product:catalog` = 1 h, `config:flags` = 60 s.
- **TTL jitter (required):** if 100k keys share TTL=600, they expire together and stampede the DB. Spread expiries:

```
def ttl_with_jitter(base, frac=0.2):
    return base + random.uniform(-base * frac, base * frac)
```

Verified output (base=600): `[558, 516, 636, 497, 609]` — expiries spread over ~±2 min.
- Store value and TTL together: `SET key value EX <jittered>` (single command; verified: `TTL` returns 60 after `SET x 1 EX 60`). Prefer `SET ... EX` over legacy `SETEX` in new code — same effect, one code path.

### 4. Stampede / thundering-herd protection

When a hot key expires, N concurrent requests all miss and hit the DB at once. Layer defenses:

**a) Request coalescing (singleflight concept):** in-process, only the first concurrent caller for a key does the DB load; the rest await its result. Implement per-process (a `dict[key, Future]` guard); across processes use (b).

**b) Rebuild lock with SET NX PX (distributed, works across processes):**

```
# Acquire: succeeds for exactly one worker; PX auto-expires the lock on crash.
acquired = redis.set(f"lock:{key}", token, nx=True, px=5000)  # SET lock KEY NX PX 5000
```

Verified: worker-1 gets `True`, worker-2 gets `None` while the lock is held.

```
# Safe release: delete ONLY if the lock still holds OUR token (else we'd
# delete a lock a crashed-and-reacquired worker now owns). In real Redis
# this is atomic Lua; fakeredis lacks EVALSHA so logic was verified client-side.
-- EVAL this in real Redis:
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end
```

Losers don't hit the DB: they wait ~50–100 ms and retry the GET, or serve slightly-stale while the winner rebuilds.

**c) Probabilistic early refresh:** refresh a hot key before it expires, with probability rising as TTL shrinks — spreads rebuilds over time instead of at the expiry instant:

```
def should_early_refresh(ttl_remaining, base_ttl, beta=1.0):
    return ttl_remaining - random.random() * beta * base_ttl <= 0
```

A request that draws "refresh" rebuilds the key in the background (or holds the lock) while other requests keep serving the still-valid cached value. Beta ~1.0 is the standard starting point; raise it for hotter keys.

### 5. Invalidation strategies

- **Explicit delete on write (default):** shown in the cache-aside write path above. Simple, correct, sufficient for most cases.
- **Versioned keys:** embed a version in the key — `price:v3`. To "invalidate everything," bump the version (`price:v4`); old keys expire naturally. Best when a whole class of keys must flip at once (deploy, config rollout).
- **Short TTL as eventual invalidation:** for data that changes on its own schedule (leaderboards, counts), set TTL = acceptable staleness and never invalidate explicitly. Less code, honest about the trade-off.
- **Tag-based invalidation (noted pattern):** maintain a set per tag (`tag:product:42 -> {keys...}`) and delete all members on invalidation. Powerful, but the tag index is its own consistency problem — reach for it only when explicit delete can't express the invalidation ("all keys touched by product 42").

### 6. Serialization and key naming

- **Keys:** `entity:id[:field]` — lowercase, colon-separated, no spaces: `user:42`, `user:42:profile`, `cfg:price:v3`. Prefix by domain; include the version segment when using versioned keys. Keep keys short and deterministic — they're memory too.
- **Serialization:** JSON for human-debuggable values (verified round trip: `'{"name":"anthony"}'` stored and retrieved intact). Use MessagePack/protobuf only when profiling shows JSON is the bottleneck. Never store language-specific pickles in a shared cache.
- Store one logical value per key; don't pack unrelated fields into one blob (it couples their TTLs and invalidation).

### 7. Failure mode: cache down

The cache is an optimization, not a dependency:

```
try:
    hit = redis.get(key)
except RedisError:
    hit = None          # degrade to DB; NEVER fail the request because the cache is down
```

Log and alert on cache errors (a dead cache silently becomes a DB-DDoS), add a circuit breaker if failures persist, but the request path must always fall through to the source of truth.

## Output Contract

When asked to add caching, deliver:

1. The chosen pattern (default: cache-aside) with the read path, write path, and invalidation step stated explicitly.
2. Per-key TTLs with the volatility/recompute/stale-cost reasoning, each passed through `ttl_with_jitter`.
3. Stampede plan for the hottest key: which of coalescing / SET-NX-PX lock / probabilistic early refresh, and what losers do while the winner rebuilds.
4. Key-naming scheme and serialization choice.
5. The failure path: exact behavior when Redis is unreachable.
6. A "do not cache" list: which data was excluded and why (low read/write ratio, correctness-critical, tiny/fast already).

## Operating Rules

1. **Default to cache-aside.** Use write-through only when stale-on-write is unacceptable; write-behind only when write volume is extreme AND data loss is tolerable — say so explicitly.
2. **Invalidate by DELETE, not by SET**, on the write path (avoids the concurrent-write race).
3. **Every TTL gets jitter.** No shared round-number TTLs on hot keys, ever.
4. **SET lock keys with NX + PX together** — never separate SETNX + EXPIRE (crash between them = deadlock). Releases must check the token (Lua on real Redis).
5. **Never invent Redis commands.** Verified real: `GET`, `SET` (with `EX`/`PX`/`NX` options), `SETEX`, `DEL`, `EXPIRE`, `TTL`, `EVAL` (Lua). If a pattern needs a command you can't confirm, label it "pattern sketch — verify against your Redis version."
6. **Cache misses and errors are observability events.** Track hit rate per key prefix; a sudden miss-rate spike is your stampede alarm.
7. **Never fail the request because the cache is down.** Degrade to the DB, log, alert — in that order.
