Caching Patterns and Their Failure Modes
· Jerwin Arnado · 7 min read ·
Part eight of the series, opening Speed & Failure. The Caching & CDN stack post covered where caches sit in a request. This chapter is about the strategies for reading and writing through them — and, more importantly, the ways a cache stops being a speedup and becomes the thing that takes you down. Because a cache is never free: every cache is a bet that slightly stale data is acceptable, in exchange for speed. The patterns are how you place that bet; the failure modes are how the bet goes wrong.
The write patterns
Three ways to keep a cache and a database in sync, differing in when the write hits each:
- Cache-aside (lazy loading): the app manages the cache. Read: check cache; on a miss, read the DB and populate the cache. Write: update the DB and delete the cache entry (let the next read repopulate). The overwhelming default — simple, and the cache only holds data someone actually asked for.
- Write-through: write to the cache and the DB together, synchronously, on every write. The cache is always warm and consistent — at the cost of slower writes (two writes every time) and caching data that may never be read.
- Write-behind (write-back): write to the cache immediately, flush to the DB asynchronously later. Fastest writes, but a cache crash before the flush loses data — rarely worth the risk for a system of record.
Cache-aside in Laravel is the pattern you’ll write 95% of the time:
// Read: serve from cache, populate on miss (cache-aside)
$clinic = Cache::remember("clinic:{$id}", now()->addMinutes(10), function () use ($id) {
return Clinic::with('services')->findOrFail($id); // the expensive query, only on a miss
});
// Write: update the DB, then INVALIDATE (don't try to update the cached value)
$clinic->update($data);
Cache::forget("clinic:{$id}"); // next read repopulates from the source of truth
Prefer invalidate (delete) over update on writes — deleting is race-safe (the next read rebuilds from the source of truth), whereas writing the new value into the cache invites a stale write from a slower concurrent request to win.
Invalidation: the genuinely hard part
“There are only two hard things in computer science: cache invalidation and naming things.” The joke is load-bearing. A cache is a copy, and the instant the source of truth changes, the copy is a potential lie. Invalidation is deciding when the copy is no longer trustworthy, and it’s hard because the events that should invalidate a cached value are often far away from where it was cached. Two disciplines keep it manageable:
- TTL as a safety net. Every cached entry gets an expiry, even if you also invalidate explicitly. TTL bounds how wrong the cache can be — a 10-minute TTL means worst case, 10 minutes stale. It’s the backstop for the invalidations you forget to wire up.
- Event-based invalidation for correctness. When the data changes, forget the key. TTL alone means always-a-little-stale; explicit invalidation on write means fresh-when-it- matters, with TTL catching what you miss.
The failure mode that causes outages: the stampede
Here’s where a cache turns into an outage. A cache stampede (or “thundering herd”): a popular key expires, and in the instant before anything repopulates it, every concurrent request misses the cache at once and slams the database with the same expensive query simultaneously.
Hot key "homepage:stats" (served 5,000 req/s from cache) expires at 12:00:00.000
12:00:00.001 → 5,000 requests all miss → 5,000 identical heavy queries hit the DB
→ DB CPU to 100% → queries slow → more requests pile up → the cache that
protected the DB just became a synchronized DDoS against it.
The cache was carrying 5,000 req/s; the DB was never sized for that. One expiry and the dam breaks. The fixes:
- Lock/single-flight: on a miss, the first request takes a lock and does the query;
the rest wait for it to populate the cache, then read the fresh value. Only one query
hits the DB. Laravel’s
Cache::lock()(or atomicrememberunder some drivers) gives you this. - TTL jitter: never expire many keys at the same instant. Add randomness —
now()->addMinutes(10)->addSeconds(rand(0, 120))— so expirations scatter instead of synchronizing into a herd. - Probabilistic early refresh: refresh a hot key slightly before it expires, in the background, so it’s never actually cold at expiry.
Stampede protection is the difference between a cache that gracefully degrades and one whose expiry is a scheduled outage.
Other failure modes worth knowing
- Cache penetration: requests for keys that don’t exist (often malicious — random IDs) always miss and always hit the DB, because there’s nothing to cache. Fix: cache the “not found” result too, briefly, so repeated misses for the same bad key are absorbed.
- The cache as a hidden dependency: if your app can’t serve anything when Redis is down, your cache became a critical dependency — you added a single point of failure while trying to add speed. Decide deliberately: degrade to hitting the DB directly (slower but up), or fail. “Redis down = whole site down” should be a choice, not a surprise.
- Stale-forever bugs: an invalidation that silently fails leaves a wrong value cached until its TTL — which is why TTL-as-a-backstop matters even when you invalidate explicitly. Never cache with no expiry.
Caveats and best practices
- Always set a TTL, even with explicit invalidation. The TTL is the backstop for every
invalidation you forget or that fails silently.
Cache::forever()is a bug generator — the one value you can never fix without a deploy is the one that goes wrong. - Invalidate (delete), don’t update, on writes. Deletion is race-safe; the next read rebuilds from the source of truth. Writing new values into the cache invites concurrent stale writes to win.
- Protect hot keys from stampede before they’re hot. Add TTL jitter everywhere by default, and single-flight locking on anything expensive. The stampede only shows up under the load you didn’t test at.
- Decide what happens when the cache is down. Graceful degradation (fall through to the DB) or explicit failure — but choose it. An unplanned “cache is a hard dependency” is how a Redis blip becomes a full outage.
- Cache the source of truth’s key, and mind multi-tenancy. A tenant-varying cache key without the tenant in it leaks one customer’s data to another — the caching version of a security bug.
Conclusion
Patterns → cache-aside (default), write-through (warm+consistent, slow writes),
write-behind (fast, risks data loss)
Writes → invalidate, don't update; deletion is race-safe
Invalidation → TTL as backstop + event-based delete for correctness
Stampede → hot key expires, all requests miss at once, DB melts
→ single-flight lock + TTL jitter + early refresh
Down → decide: degrade to DB, or fail — never by accident
A cache trades a little staleness for a lot of speed, and it keeps that bargain right up until a hot key expires and the herd tramples your database. The strategies are straightforward; the discipline is in the failure modes — jitter your TTLs, single-flight your hot keys, always set an expiry, and decide up front what happens when the cache itself goes down. Next: designing for failure — because the cache is only one of the things that will break.