Caching with Redis
Cache-aside, write-through, TTL, invalidation.
"There are only two hard things in computer science: cache invalidation and naming things." Get cache invalidation right and Redis is the cheapest 100× speedup you'll ever deploy. Get it wrong and users see stale data forever.
The big idea
A cache is a fast, small store of computed answers. Three things define a cache: when you populate it, when you invalidate it, and what happens when it's wrong. A cache without an answer to all three is a memory leak.
The standard strategies
| Attribute | Strategy | Trade-off |
|---|---|---|
| Cache-aside | App reads cache first; on miss, reads DB and writes back to cache. | Simple. Stale reads possible if the DB changes without invalidation. |
| Write-through | Every write goes to cache + DB in lock-step. | Always-fresh reads; slower writes; cache and DB can drift on partial failure. |
| Write-behind | Write to cache, flush to DB asynchronously. | Fastest writes; risk of data loss if the cache dies before flushing. |
| Refresh-ahead | Background job re-populates hot keys before they expire. | Hides latency for predictable hot keys; wastes work on cold ones. |
Cache-aside in code
The default for most apps:
async function getUser(id: number): Promise<User | null> {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const user = await db.user.findUnique({ where: { id } });
if (user) {
await redis.set(key, JSON.stringify(user), 'EX', 300); // 5 min TTL
}
return user;
}
async function updateUser(id: number, patch: Partial<User>) {
await db.user.update({ where: { id }, data: patch });
await redis.del(`user:${id}`); // invalidate
}import json
async def get_user(id: int) -> User | None:
key = f"user:{id}"
cached = await redis.get(key)
if cached:
return User(**json.loads(cached))
user = await db.user.find_unique(where={"id": id})
if user:
await redis.set(key, json.dumps(user.dict()), ex=300) # 5 min TTL
return user
async def update_user(id: int, patch: dict) -> None:
await db.user.update(where={"id": id}, data=patch)
await redis.delete(f"user:{id}") # invalidateThree things to notice: TTL on the SET, explicit invalidation on writes, and
serialisation (Redis stores strings).
TTLs and invalidation
Two ways out of "stale forever":
Time-based (TTL)
Every key expires after
EX seconds. Pick a TTL short enough to be tolerable, long enough to be useful. 5 minutes is a sane default; 1 second is a denial-of-service on your DB.Event-based
On every write,
DELthe affected keys. The cache repopulates on next read. More consistent, but you must know every key affected by a write.
Most production systems use both: event-based invalidation for known writes plus a TTL as a safety net for the bugs you missed.
Common pitfalls
Eviction policy
Redis must drop something when it runs out of memory. Configure maxmemory-policy
explicitly — the default depends on the version and surprises everyone.
- allkeys-lru
- Drop least-recently-used keys; safe default for caches.
- volatile-lru
- Same but only keys with a TTL — use if Redis also holds non-cache state.
- allkeys-lfu
- Least-frequently-used; better for skewed access patterns.
- noeviction
- Reject writes when full. Use only when Redis is your source of truth, never as a cache.
Beyond GET/SET
Redis has data types that make it more than a key-value store:
# Counter
INCR rate:42:minute
EXPIRE rate:42:minute 60
# Set (for "have I seen this user today?")
SADD daily_active:2025-11-12 42
# Sorted set (leaderboard)
ZADD leaderboard 1500 alice
ZRANGE leaderboard 0 9 REV WITHSCORES
# Pub/sub
PUBLISH chat:room42 "hello"
SUBSCRIBE chat:room42In practice
The biggest cache wins are at the boundary of a slow operation: a complex aggregation, a third-party API call, a per-page render. Cache the answer, not the input. And keep the cache simple — when you find yourself building a "cache that's almost a DB," you've gone too far.
Key takeaways
- Cache-aside (read-through with explicit invalidation) is the default strategy.
- TTLs are your safety net; event-based invalidation is your primary mechanism.
- Beware the thundering herd — jitter, locks, or refresh-ahead.
- Set `maxmemory-policy` explicitly; the default surprises everyone.
- Redis is more than a key-value store — counters, sets, sorted sets, pub/sub.
Checkpoint questions
Use these to test whether the lesson is clear enough to explain without rereading.
- 1Which cache strategy fits a read-heavy endpoint with occasional writes?
- 2What could make cached data stale, and how would you invalidate it?
- 3How do TTLs reduce risk without fully solving correctness?
- 4What is a cache stampede, and how can you reduce it?
References
External resources for going deeper after the lesson above.