application-caching-strategy

verified

8d406d00-b9f4-400a-8f2f-09ea382e6f78

Decide what to cache in an application, where (in-process, Redis, CDN), and how to invalidate safely. Use when repeated expensive work dominates runtime.

Metadata

Skill ID
8d406d00-b9f4-400a-8f2f-09ea382e6f78
Version
1
Owner
387274b7-2891-478b-81b8-e11d5adb9319
Tags
cachingredisinvalidationcachettlperformancememoization
Signature
verified
Integrity
OK
Content hash
73be6942a15c22a4f86b258dd16fb2da73409938f81f4afbbde966c49c6524ff
Created
2026-08-15T05:24:21Z

Skill file

Raw skill file (markdown source)
# Application Caching Strategy

Use when repeated expensive work dominates runtime — decide *what* to cache, *where* to cache it, and *how to invalidate* it safely. Caching is a tradeoff, not a default.

## The Caching Decision Table

Ask these five questions before caching anything:

| Question | Cache if... | Don't cache if... |
|---|---|---|
| Is it **expensive** to compute? | DB query >10ms, external API, CPU-heavy | Simple attribute access, in-memory lookup |
| Is it **frequently read**? | Read many times per compute | Read once (cache adds overhead) |
| Is it **staleness-tolerant**? | A few seconds/minutes of staleness is OK | Must be exactly current (bank balance) |
| Is it **shared** or **per-user**? | Shared data (config, catalog) | Per-user data (needs user-scoped keys) |
| Is it **deterministic**? | Same input → same output | Random, time-dependent, or side-effecting |

**Rule of thumb**: Cache only when the answer to "expensive?" AND "frequent?" is yes, and "staleness-tolerant?" is yes.

## Cache Levels

| Level | Tool | Best for | Example |
|---|---|---|---|
| In-process | `functools.lru_cache` | Single-process, per-call memoization | Parsing a repeated config string |
| Local process | `cachetools.TTLCache` | Per-process with expiry | In-memory session cache |
| Distributed | Redis / Memcached | Multiple processes/servers | Shared product catalog |
| HTTP edge | CDN (CloudFront, Cloudflare) | Static/rarely-changing responses | Images, JS bundles, cached API responses |

### In-Process (functools.lru_cache)
```python
from functools import lru_cache

@lru_cache(maxsize=256)
def get_product_price(sku: str) -> float:
    # Only call the DB once per unique sku per process lifetime
    return db.query_price(sku)
```

**Warning**: `lru_cache` has NO expiry. If the underlying data changes, the cache stays stale until process restart. Use `cachetools.TTLCache` for anything that changes.

### Redis (distributed, with TTL)
```python
import redis
import json

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

def get_cached_product(sku: str):
    cached = r.get(f"product:{sku}")
    if cached:
        return json.loads(cached)
    product = db.query_product(sku)
    r.setex(f"product:{sku}", ttl_seconds=300, value=json.dumps(product))  # 5-min TTL
    return product
```

## Key Design (namespace + version)

```
{namespace}:{entity}:{id}:{version}
product:sku-123:v3        # version bump invalidates everything
user:42:profile
config:feature-flags:v2
```

**Version in the key** is the bluntest, most reliable invalidation: change the version → all old keys become unreachable garbage (cleaned up by TTL).

## Invalidation Strategies

| Strategy | How | Pros | Cons |
|---|---|---|---|
| **TTL** | Set an expiry (`setex`) | Simple, self-healing, no coordination | Stale until expiry; guessing TTL |
| **Explicit invalidation** | Delete key on write (`r.delete(key)`) | Fresh immediately | Must catch *every* write path |
| **Cache-aside** | Read: check cache → miss → compute → store. Write: update DB → delete cache | Standard, well-understood | Race conditions (see pitfalls) |
| **Write-through** | Write to cache and DB together | Cache always fresh | Slower writes; write amplification |
| **Versioned keys** | `key:vN`, bump N on change | No deletion races | Old keys linger until TTL |

### Cache-aside with explicit invalidation (the workhorse)
```python
def update_product(sku: str, data: dict):
    db.update_product(sku, data)      # 1. Write to DB (source of truth)
    r.delete(f"product:{sku}")        # 2. Invalidate the cache
    # Next read will miss, recompute, and repopulate
```

## Guardrails

- **Never** cache mutable shared state (a mutable object shared across threads) — one consumer mutates it and corrupts everyone.
- **Never** cache without an expiry — a cache that never expires is a permanent stale-read bug waiting to happen.
- **Never** cache security-sensitive data (passwords, tokens, PII without encryption) — caches are not access-controlled.
- **Always** treat the DB as the source of truth — the cache is disposable. If in doubt, evict.

## Pitfalls

- **Caching mutable/shared state**: Returning the *same* dict/list object from `lru_cache` means callers can mutate it. Return copies or immutable structures:
```python
@lru_cache(maxsize=128)
def get_config():
    return tuple(sorted(config_items))   # immutable, safe to share
```
- **Stale reads after writes**: You write to the DB but forget to invalidate the cache — readers get old data. Every write path must invalidate.
- **A cache that never expires**: `lru_cache` has no TTL; a misconfigured Redis key with no `setex` lives forever. Always set an expiry.
- **Cache stampede**: A hot key expires, and 1000 concurrent requests all miss and hammer the DB. Use locking or jittered TTLs:
```python
import random
ttl = base_ttl + random.randint(0, 30)   # jitter prevents synchronized expiry
```
- **Caching the wrong thing**: Caching a cheap in-memory lookup adds overhead without benefit. Profile first (see `python-profiling`).

## Verify / Checklist

- [ ] Cache decision table applied — only expensive + frequent + staleness-tolerant data is cached
- [ ] Correct cache level chosen (in-process vs Redis vs CDN) for the process topology
- [ ] Every cache key has a namespace + (where needed) a version
- [ ] Every cached value has an expiry (TTL) — no infinite caches
- [ ] Every write path invalidates the corresponding cache entries
- [ ] No mutable shared state returned from cache (copies or immutable structures)
- [ ] No security-sensitive data cached without encryption
- [ ] Cache hit-rate measured before/after (e.g., Redis `INFO stats` → `keyspace_hits`/`misses`)
- [ ] Stampede protection in place for hot keys (locking or jittered TTL)

Attached files

No attached files.