Redis Caching Patterns That Survive Real Traffic
Cache-aside with the two mandatory upgrades — TTL jitter and stampede protection — plus version-stamped invalidation, the after-commit trap, and the three metrics that keep a cache honest.
Redis is usually the first piece of infrastructure a team adds after the database, and caching is usually why. It's also where I see the same four production incidents on repeat: the stampede, the stale-forever key, the cache that quietly became the database, and the invalidation bug that only reproduces on Thursdays. Let's build the caching layer that doesn't do those things.
Cache-aside, the workhorse — and its two mandatory upgrades
The pattern everyone starts with: look in cache, on miss read the source and fill the cache. In Laravel it's one call:
$products = Cache::remember(
"catalog:featured:v2", // note the version suffix — more below
now()->addMinutes(15),
fn () => Product::featured()->with('brand')->get(),
);
Correct, but naive at scale. Upgrade one: TTL jitter. If a deploy warms a thousand keys with identical 15-minute TTLs, they all expire in the same second fifteen minutes later, and your database meets a synchronized herd. Randomize: 15 minutes + rand(0, 90) seconds. It's one line and it deletes an entire incident category.
Upgrade two: stampede protection for expensive keys. When a hot key expires, a hundred concurrent requests all miss simultaneously and all run the 800ms query. You want exactly one of them to; the rest wait or serve stale:
// Laravel has this built in — flexible() serves stale while ONE
// process refreshes in the background (stale-while-revalidate):
$stats = Cache::flexible('dashboard:stats', [60, 300], fn () => $this->computeStats());
// fresh for 60s; between 60–300s: serve stale + refresh async; after 300s: block
// The manual version elsewhere: a lock around the recompute
$lock = Cache::lock('lock:dashboard:stats', 10);
if ($lock->get()) { /* recompute, fill, release */ }
else { /* serve stale or briefly wait */ }
Reach for this on anything where the recompute is heavier than ~100ms or the key is hit more than ~10×/sec. For cheap keys, the plain version is fine — stampedes need both heat and weight to hurt.
Invalidation: pick explicit strategies, not vibes
The two clean strategies, in order of preference:
1. Version-stamped keys (my default). Never delete; change the key. Keep a version counter per entity or collection, bump it on write, and build keys from it:
// On any product write:
Cache::increment('ver:catalog'); // cheap, atomic
// On read:
$ver = Cache::get('ver:catalog', 1);
$key = "catalog:featured:v{$ver}";
Old entries die by TTL; there's no delete to forget, no race between delete and refill, and "invalidate everything catalog-ish" is one INCR. The cost is a tiny extra GET per read — Redis yawns at it.
2. Event-driven deletes for precise, single-entity keys: model observer or domain event listener deletes product:{id} on update. Fine — but write the delete after the DB commit (in Laravel, DB::afterCommit), or a rolled-back transaction leaves you having deleted a valid cache and cached nothing, while a parallel reader refills it with pre-rollback data. That's the Thursday bug.
The strategy to refuse: wildcard KEYS pattern* + DEL sweeps in production. KEYS is O(N) over the whole keyspace and blocks the single-threaded server; at minimum use SCAN, but if you're scanning to invalidate, that's the smell telling you to use version stamps.
What to cache — and the things people cache that they shouldn't
Great candidates: rendered fragments and API responses that are read-heavy and tolerance-tolerant (product pages, dashboards, settings), expensive aggregations, external API responses (with TTL matched to the provider's freshness), and authorization-adjacent lookups that fail closed.
Terrible candidates I keep meeting: anything you can't recompute (cache is a performance layer, not storage — if Redis flushing would lose data, that data is in the wrong place); per-user keys with unbounded cardinality and no TTL (your maxmemory policy will eventually evict something you cared about); and permission checks cached longer than your tolerance for "we revoked their access an hour ago and they're still in".
The observability that separates pros from hopefuls
A cache without metrics is a rumor. Three numbers on a dashboard: hit rate (per key-family, not global — a 95% overall rate can hide a 0% rate on your most expensive key), p99 of the miss path (that's your real worst-case latency, and it's what the stampede multiplies), and evictions per second (nonzero means memory pressure is silently shortening your TTLs — the config post covers the maxmemory tuning). Tag cache reads in your traces and the "why is this page slow sometimes" ticket answers itself.
Caching done right is unglamorous: boring keys with versions in them, jittered TTLs, one lock around the expensive stuff, and three graphs. Done wrong, it's the most creative bug generator in your stack. Choose boring.
Next in the Redis series: everything Redis does besides cache — locks, rate limits, queues, streams. And if your hit rate is a mystery, I audit caching layers with unreasonable enthusiasm.