Caching Strategies
Caching is storing the result of expensive work so you can reuse it instead of redoing it — the single highest-leverage performance technique in most applications. A query that takes 200ms against the database takes microseconds from memory. But caching introduces the hardest problem in computer science: invalidation — keeping the cache from serving stale data. This page covers the layers of caching (in-process, distributed, HTTP, CDN), the read/write patterns, TTL and eviction, the invalidation strategies, and the pitfalls (stampedes, stale data) that bite in production.
The caching layers — closer to the user is faster
Layer | Where | Latency | Scope |
|---|---|---|---|
In-process ( | Inside the Node process | Nanoseconds | Per-instance — not shared |
Distributed (Redis, Memcached) | Separate server | Sub-millisecond (network) | Shared across all instances |
HTTP cache | Browser / proxy | Zero server work on hit | Per-client / shared proxy |
CDN | Edge, near the user | Edge latency | Global, for static/cacheable content |
Cache-aside — the default read pattern
async function getProduct(id) {
const key = `product:${id}`
// 1. Try the cache first:
const cached = await redis.get(key)
if (cached) return JSON.parse(cached) // cache HIT
// 2. Miss → load from the source of truth:
const product = await db.products.findById(id)
// 3. Populate the cache with a TTL, then return:
await redis.set(key, JSON.stringify(product), 'EX', 300) // expire in 5 min
return product
}TTL and eviction
// In-process LRU with a size bound AND a time bound:
import { LRUCache } from 'lru-cache'
const cache = new LRUCache({
max: 5000, // evict least-recently-used past 5000 entries (bounds memory)
ttl: 1000 * 60, // entries expire after 60s (bounds staleness)
})
// Redis: every key gets an expiry — never cache without one:
await redis.set('session:abc', data, 'EX', 1800) // 30-minute TTLInvalidation — the hard part
Strategy | How | Trade-off |
|---|---|---|
TTL expiry | Let entries expire after N seconds | Simple; serves stale data up to the TTL |
Explicit eviction | Delete the key when the data changes | Fresh; you must find every write path |
Write-through | Update cache on every write | Always consistent; adds write latency |
Versioned keys | Bump a version in the key ( | Old versions age out naturally; no deletes |
The cache stampede problem
// ❌ When a popular key expires, hundreds of concurrent requests all miss at once
// and hammer the database simultaneously (the "thundering herd").
// ✅ Single-flight: collapse concurrent misses into ONE database load:
const inFlight = new Map()
async function getProduct(id) {
const key = `product:${id}`
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
if (inFlight.has(key)) return inFlight.get(key) // reuse the in-progress load
const promise = db.products.findById(id).then(async (p) => {
await redis.set(key, JSON.stringify(p), 'EX', 300)
inFlight.delete(key)
return p
})
inFlight.set(key, promise)
return promise
}What to cache — and what not to
Cache: expensive reads that are read far more than written — product catalogs, config, computed aggregates, rendered fragments.
Cache: idempotent external API responses — to cut latency and respect rate limits.
Don't cache: per-user sensitive data in a shared cache without correct key scoping — you risk leaking one user's data to another.
Don't cache: rapidly-changing data where staleness causes real harm (live inventory, balances) unless TTL is tiny.
Measure the hit rate — a cache with a low hit rate adds complexity and latency for little gain.
Beware caching errors/empty results — a cached 404 or null can mask data that later appears.