Learn / Databases, past the CRUD layer
Caching and invalidation
Lesson 9 of 37 · 9 min read ·
What it is
A cache trades freshness for speed. Everything hard about caching follows from that one sentence: you are deliberately serving data that might be wrong, and your job is to bound how wrong and for how long.
The patterns
Cache-aside (lazy loading) — the default, and what most people mean by "caching".
let user = await cache.get(key)
if (!user) {
user = await db.getUser(id)
await cache.set(key, user, { ttl: 300 })
}
return user
Only requested data is cached; a cache miss costs one extra round trip; a cache outage degrades to slow, not broken.
Write-through — write to cache and database together. The cache is never stale, but every write pays cache latency, and you cache things nobody reads.
Write-behind — write to cache, flush to the database asynchronously. Fast writes, and a genuine risk of data loss on failure. Rare, and rarely right.
Refresh-ahead — proactively refresh popular keys before they expire. Good for a small hot set, wasteful otherwise.
Invalidation
Three strategies, in increasing order of effort:
- TTL. Simple, self-healing, always eventually correct. Pick the TTL from how stale the data may be: a product price maybe 60 s, a user profile 5 min, a country list a day.
- Explicit invalidation on write. Delete the key when the underlying row changes. Precise, but you must find every write path, and you will miss one. Delete rather than update — an updated cache entry can be overwritten by a slower concurrent write.
- Versioned keys. Put a version in the key (
user:42:v7). Bump the version instead of deleting; old entries expire on their own. Avoids the race entirely.
Combine 1 and 2: invalidate on write for correctness, keep a TTL as the safety net for the write path you forgot.
The failure modes that actually page you
Cache stampede. A popular key expires; 500 concurrent requests all miss and all hit the database at once, which falls over. Fixes: a short lock so one request repopulates while others wait, or serving stale-while-revalidate, or jittered TTLs so keys do not expire in lockstep.
Unbounded growth. A cache with no eviction policy is a memory leak with better PR. Set maxmemory and an eviction policy (allkeys-lru is usually right).
Caching the error. A failed lookup returns null, you cache null for five minutes, and now a transient blip is a five-minute outage for that key. Cache negatives briefly and deliberately, or not at all.
Reading your own write. User updates their profile, gets redirected, sees the old value, and reports a bug. Invalidate before responding, not after.
Gotchas
- Include everything that changes the result in the key: locale, user role, feature-flag state. A cache key that omits a variant will serve one user's view to another.
- Never cache authorised data under a key that is not scoped to the viewer. This is a data-leak bug, not a performance bug.
- Measure hit rate. Below ~80% the cache may be adding latency rather than removing it.
- A cache is not a database. It must be safe to flush at any moment.
Prove you know it
Pick your hottest read endpoint. Write down: the key (including every variant), the TTL and why that number, what invalidates it on write, and what happens if Redis is down. If you cannot answer all four, the cache is not designed yet.