Caching Patterns
Implementing cache-aside with real Redis commands, plus the cache stampede problem and mitigations.
This page covers the actual Redis commands behind the cache-aside pattern. For the conceptual background — where caches sit in a system, and how cache-aside compares to write-through/write-behind — read load-balancing-caching.md in this app's System Design track first; this page picks up from there with concrete implementation.
Cache-aside, implemented
The cache-aside (lazy-loading) pattern in practice is exactly two Redis commands wrapping a database read: check the cache first, fall back to the database on a miss, then populate the cache for next time.
GET product:1042
If that returns nil (a cache miss), the application queries the database directly, then stores the result with an expiry:
SET product:1042 '{"name":"Wireless Mouse","price":24.99,"stock":150}' EX 300
EX 300 sets a 300-second (5 minute) time-to-live — after that, the key disappears from Redis automatically, and the next request for it is a guaranteed cache miss that re-populates it from the database. In application pseudocode:
function getProduct(id) {
let cached = redis.get(`product:${id}`);
if (cached !== null) {
return JSON.parse(cached); // cache hit — no database round-trip
}
let product = db.query("SELECT * FROM products WHERE id = ?", [id]);
redis.set(`product:${id}`, JSON.stringify(product), "EX", 300);
return product;
}
On a write to the underlying data, the simplest correct move is to delete (invalidate) the cached key rather than trying to update it in place — the next read simply repopulates it:
DEL product:1042
function updateProduct(id, changes) {
db.query("UPDATE products SET ... WHERE id = ?", [id]);
redis.del(`product:${id}`); // invalidate; next GET repopulates from the DB
}
Deleting rather than updating the cache on write avoids a whole class of bugs where the cache update logic drifts out of sync with the database write logic over time — there's only one place ("the database") that's ever the source of truth for what the value should be.
The cache stampede problem
A cache stampede (also called a "thundering herd") happens when a popular key expires and many concurrent requests all miss the cache at the same instant — every one of them queries the database simultaneously to repopulate the same key, spiking database load right when the cache was supposed to be protecting it.
key "product:1042" expires at T
at T, 500 concurrent requests all GET product:1042 -> all miss
all 500 requests hit the database simultaneously for the same query
This is especially damaging for expensive queries or very popular keys ("hot keys"), where the database can be hit with hundreds of duplicate identical queries in the same instant.
Mitigation: jittered TTLs
Setting every cache entry to expire at exactly the same round number (EX 300 for everything) means keys populated around the same time also expire around the same time, synchronizing future stampedes. Adding random jitter spreads expirations out:
const baseTTL = 300;
const jitter = Math.floor(Math.random() * 30); // 0-30 seconds of randomness
redis.set(`product:${id}`, JSON.stringify(product), "EX", baseTTL + jitter);
Mitigation: a lock around the recompute
A more robust fix uses a short-lived lock key so only one request repopulates the cache while others wait (or briefly serve a slightly stale value):
SET product:1042:lock "1" EX 5 NX
NX ("only set if the key does Not eXist") makes this atomic: exactly one concurrent caller succeeds in setting the lock; every other caller's SET ... NX fails and knows another request is already handling the recompute.
function getProduct(id) {
let cached = redis.get(`product:${id}`);
if (cached !== null) return JSON.parse(cached);
let gotLock = redis.set(`product:${id}:lock`, "1", "EX", 5, "NX");
if (gotLock) {
let product = db.query("SELECT * FROM products WHERE id = ?", [id]);
redis.set(`product:${id}`, JSON.stringify(product), "EX", 300);
redis.del(`product:${id}:lock`);
return product;
}
// Another request is already recomputing it — brief wait and retry,
// or serve slightly stale data if available
sleep(50);
return getProduct(id);
}
Common mistakes
- Setting no TTL at all "to be safe," which just means the cache never naturally corrects itself if something falls out of sync — cache entries should almost always expire eventually.
- Updating a cached value's fields in place on every database write instead of deleting the key — this doubles the number of places that must correctly express "what changed," and is a frequent source of cache/database drift bugs.
- Giving every cache entry the exact same fixed TTL, which synchronizes expirations across many keys and makes a stampede more likely, not less.
- Reaching for a lock-based stampede mitigation before confirming the simpler jittered-TTL fix isn't already sufficient — jitter alone solves the vast majority of real stampede problems with far less code.