Redis Caching Patterns: TTL, Stampede, and Invalidation
Practical Redis caching patterns: cache-aside, write-through, TTL choices, stampede protection, and invalidation strategies that survive production.
What you'll learn
- ✓Implement cache-aside, write-through, and write-behind
- ✓Pick TTLs that age gracefully
- ✓Stop cache stampedes with locks and probabilistic refresh
- ✓Invalidate without dataloss or thrash
- ✓Avoid the classic cache footguns
Prerequisites
- •Comfort with key-value stores
- •Read [What is REST](/blog/what-is-rest)
- •Optional: [What is Node.js](/blog/what-is-nodejs)
Caching is a deal: trade staleness and complexity for latency and cost. Redis makes the mechanics easy. The hard part is picking the right pattern and protecting it from the failure modes most tutorials skip.
The four patterns
- Cache-aside: app reads the cache, falls back to the source, writes back.
- Read-through: a library or proxy fetches on miss.
- Write-through: every write hits cache and source synchronously.
- Write-behind: writes go to cache first, flushed later.
Most services should start with cache-aside. It is explicit, it survives Redis going down, and it composes well.
Cache-aside in Node
import Redis from "ioredis";
const redis = new Redis();
async function getUser(id: string) {
const key = `user:${id}`;
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
if (!row) return null;
await redis.set(key, JSON.stringify(row), "EX", 300);
return row;
}
Three lines of cache logic. Note EX 300 for a 5-minute TTL. Never write to Redis without a TTL unless you are running a true source of truth.
Pick TTLs that age gracefully
A single TTL across all keys creates synchronized expiry, which causes stampedes. Add jitter.
const ttl = 300 + Math.floor(Math.random() * 60);
await redis.set(key, value, "EX", ttl);
Five minutes plus or minus a minute spreads recomputation. Cheap, effective.
Stampede protection with a lock
When a hot key expires, hundreds of requests miss at once and all hammer the source. The classic fix is a short-lived lock.
async function getWithLock(key: string, ttl: number, compute: () => Promise<string>) {
const hit = await redis.get(key);
if (hit) return hit;
const lockKey = `${key}:lock`;
const ok = await redis.set(lockKey, "1", "NX", "EX", 10);
if (!ok) {
await new Promise(r => setTimeout(r, 50));
return (await redis.get(key)) ?? compute();
}
try {
const fresh = await compute();
await redis.set(key, fresh, "EX", ttl);
return fresh;
} finally {
await redis.del(lockKey);
}
}
The first miss wins the lock, computes, and stores. The rest poll briefly and pick up the result.
Probabilistic early refresh
A more elegant fix refreshes before expiry with a probability that rises near the deadline. No locks needed.
function shouldRefresh(remaining: number, beta = 1.0, delta = 0.3) {
return Math.random() < Math.exp(-beta * remaining / (delta * remaining + 1));
}
Pair with a stored expiry timestamp. Few requests recompute proactively while most read the cached value. This is the XFetch pattern.
Write-through
For data where staleness is unacceptable, write to source and cache in one transaction-shaped step.
async function updateUser(id: string, patch: object) {
const row = await db.tx(async tx => {
const r = await tx.query("UPDATE users SET ... WHERE id = $1 RETURNING *", [id]);
return r;
});
await redis.set(`user:${id}`, JSON.stringify(row), "EX", 300);
return row;
}
If the cache write fails, log it; the next read will repopulate. Never make the source depend on a cache succeeding.
Invalidation strategies
- Delete on write: simplest, but a concurrent read can refill stale data.
- Version-key: bump a version counter and embed it in the cache key. Old entries become unreachable and expire.
- Pub/sub bust: emit a message that all replicas listen for and delete locally.
Version keys are underrated. No race, no cleanup.
async function getProfile(id: string) {
const version = await redis.get(`profile:version:${id}`) ?? "0";
const key = `profile:${id}:v${version}`;
return getWithLock(key, 600, async () => JSON.stringify(await db.profile(id)));
}
async function bumpProfile(id: string) {
await redis.incr(`profile:version:${id}`);
}
Negative caching
When a lookup returns nothing, cache the absence for a short window. Otherwise a 404 storm hits your DB harder than a 200 storm.
const row = await db.query(...);
if (!row) {
await redis.set(key, "__none__", "EX", 30);
return null;
}
Don’t cache what you cannot recompute
Redis is a cache, not a database, unless you have configured persistence and replication and accepted the trade-offs. If a key disappearing breaks correctness, store it in Postgres and cache the read.
Memory and eviction
Set maxmemory and choose an eviction policy that matches your workload. allkeys-lru is a safe default for general caches. volatile-ttl favors keys nearing expiry. Monitor evicted_keys and keyspace_misses.
Pipelines and batching
Round-trip latency dominates Redis usage. Pipeline reads when fetching many keys.
const pipe = redis.pipeline();
ids.forEach(id => pipe.get(`user:${id}`));
const res = await pipe.exec();
One network round trip instead of N. The difference at scale is brutal.
Wrap up
Start with cache-aside, add jitter, protect hot keys with locks or probabilistic refresh, invalidate with versioned keys, and watch eviction metrics. Treat Redis like a cache and it will pay for itself; treat it like a database and you will pay instead.