Cache-Aside
Redis is the default in-memory cache for almost every modern app: sub-millisecond reads, atomic operations, rich data structures, and battle-tested at petabyte scale. The hard parts are eviction, consistency, and stampedes — the patterns below cover all three.
TTLs, eviction, stampedes, patterns
EXAMPLE
// 1) Basic cache-aside pattern
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getUser(id) {
const key = `user:${id}`;
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const row = await db.user.findUnique({ where: { id } });
if (row) await redis.set(key, JSON.stringify(row), 'EX', 60 * 5); // 5 min TTL
return row;
}
// 2) Always set a TTL (avoid cache pollution)
// • EX seconds — set expiry in seconds
// • PX milliseconds — set expiry in ms
// • EXAT / PXAT — absolute timestamp
// • NX — set only if missing (atomic 'first write wins')
// • XX — set only if present
await redis.set(`session:${id}`, token, 'EX', 86400);
await redis.set(`lock:job:${id}`, '1', 'EX', 30, 'NX');
// 3) Eviction policies (configure on the server)
// maxmemory-policy:
// noeviction — refuse writes when memory is full (good for a strict store)
// allkeys-lru — evict least recently used (general cache default)
// allkeys-lfu — evict least frequently used (long-tail caches)
// volatile-lru — only evict keys with TTL
// volatile-ttl — evict keys nearest to expiry
// allkeys-random — random (rare)
// Choose allkeys-lru for a cache; volatile-ttl for a mixed cache + durable store.
// 4) Read-through wrapper
async function cached(key, ttlSec, fetcher) {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const fresh = await fetcher();
await redis.set(key, JSON.stringify(fresh), 'EX', ttlSec);
return fresh;
}
const user = await cached(`user:${id}`, 300, () => db.user.findUnique({ where: { id } }));
// 5) Cache invalidation — the hard part
// On write: invalidate the affected key(s).
async function updateUser(id, data) {
const row = await db.user.update({ where: { id }, data });
await redis.del(`user:${id}`); // invalidate
return row;
}
// For collection caches (e.g. user list), tag-based invalidation:
await redis.sadd(`tag:users`, `user:${id}`);
await redis.del(`user:list`);
// 6) Stampede protection — many requests for the same missing key
// PROBLEM: when a hot key expires, every request hits the DB simultaneously.
// SOLUTION: single-flight via SET NX + a short lock.
async function getWithStampedeGuard(key, ttlSec, fetcher) {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const lock = await redis.set(`${key}:lock`, '1', 'EX', 10, 'NX');
if (lock) {
try {
const fresh = await fetcher();
await redis.set(key, JSON.stringify(fresh), 'EX', ttlSec);
return fresh;
} finally {
await redis.del(`${key}:lock`);
}
}
// Lost the race — wait briefly + try the cache again
for (let i = 0; i < 5; i++) {
await new Promise((r) => setTimeout(r, 100));
const retry = await redis.get(key);
if (retry) return JSON.parse(retry);
}
return fetcher(); // fallback — degrades gracefully
}
// 7) Probabilistic early refresh — avoid synchronised expiry
// Refresh the cache when 90% of TTL has elapsed (with random probability).
async function withRefresh(key, ttlSec, fetcher) {
const ttl = await redis.ttl(key);
if (ttl < ttlSec * 0.1 && Math.random() < 0.1) {
fetcher().then((data) => redis.set(key, JSON.stringify(data), 'EX', ttlSec)).catch(() => {});
}
const cached = await redis.get(key);
return cached ? JSON.parse(cached) : refreshOnce(key, ttlSec, fetcher);
}
// 8) Negative caching — cache 'not found' too
// Otherwise a missing key triggers a DB lookup every time.
const row = await db.product.findUnique({ where: { id } });
if (row) await redis.set(`product:${id}`, JSON.stringify(row), 'EX', 600);
else await redis.set(`product:${id}`, '__missing__', 'EX', 60); // shorter TTL
// When reading:
const hit = await redis.get(`product:${id}`);
if (hit === '__missing__') return null;
// 9) Cache stampede mitigation cheat sheet
// • TTL jitter — TTL + Math.random() * jitter — avoid mass simultaneous expiry
// • Locks — single-flight per key
// • Stale-while-revalidate — serve stale; refresh in background
// • Negative caching — cache 'not found' with short TTL
// • Lower TTLs on hot keys → less drift but more recomputation
// 10) Data types beyond strings
// • Hashes — per-row objects: HSET user:1 name 'Mara' email 'mara@example.com'
// • Lists — recent activity: LPUSH user:1:activity 'login'; LTRIM user:1:activity 0 99
// • Sets — tags / followers: SADD user:1:tags 'admin' 'ops'
// • Sorted sets — leaderboards: ZADD scores 1000 user:1 950 user:2
// • Streams — append-only log
// Hashes for object caches keep individual fields updatable without rewriting the whole blob.
// 11) Memory hygiene
redis.dbsize(); // total keys
redis.info('memory'); // used memory
redis.memory('USAGE', 'user:1'); // bytes for a single key
// Sample large key sizes:
await redis.eval(`local k = redis.call('SCAN', '0')\nreturn k`, 0); // SCAN, never KEYS
// SCAN is non-blocking; KEYS scans the whole keyspace at once and CAN take the cluster down.
// 12) Compress big payloads
import zlib from 'node:zlib';
function setCompressed(key, value, ttl) {
const buf = zlib.gzipSync(JSON.stringify(value));
return redis.set(key, buf, 'EX', ttl);
}
async function getCompressed(key) {
const buf = await redis.getBuffer(key);
return buf ? JSON.parse(zlib.gunzipSync(buf).toString()) : null;
}
// 13) Multi-tier caching
// 1. In-process LRU (lru-cache) — < 1 microsecond, no network
// 2. Redis — < 1 ms, shared across replicas
// 3. CDN — for read-mostly HTTP responses
// Use all three for high-traffic pages.
// 14) Choosing TTLs
// • Hot, rarely-updated reference data: 1 hour to 1 day
// • User profile: 5-15 minutes
// • Session data: matches your session lifetime
// • Feature flags: 30-60 seconds with active push on change
// • API responses to scraping bots: 5-60 minutes
// • NEVER 0 / no TTL on a cache key — risks unbounded memory growth
// 15) Cluster + sharding
// • Single Redis → 5-10 GB working set safely
// • Redis Cluster → millions of keys, multi-shard
// • Hash tags { } in keys keep related keys on one slot for multi-key ops:
// user:{1}:profile and user:{1}:cart hash to the same slot
// 16) Common bugs
// • Forgetting TTL — memory fills, redis evicts random keys, hot data disappears
// • Using KEYS * in production — blocks the whole instance
// • Storing massive blobs (> 1 MB) — slow to ship over the wire; compress or store elsewhere
// • Cache + DB write race — write to DB, invalidate cache; reading right after sees stale → use double-delete or stale-while-revalidate
// • Long Lua scripts — blocks the single thread
// • Mixing serialisers (JSON in one place, msgpack in another) — debugging pain
// • Single Redis as both cache and session store with noeviction policy — sessions get evicted under load → separate clusters
// • Synchronised TTLs → mass expiry → stampede; add jitter
Why it matters
Cache aside is the workhorse: read, miss, fetch, write back with a TTL. Layer in stampede locks, TTL jitter, and negative caching so a single popular miss doesn’t crater your origin, and never run KEYS * in production — SCAN is your only safe option for iterating a live keyspace.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
# Pseudocode
val = GET key
if !val:
val = db.query(...)
SETEX key 300 val
return val
Try it Yourself »
Discussion
Loading…