TL;DR
- Redis is an in-memory data-structure store - microsecond reads, not just a key-value blob.
- Cache-aside is the default pattern: check cache → miss → load DB → backfill cache.
- The hard part of caching isn't reads, it's invalidation and stampedes.
- I fixed a self-inflicted thundering-herd with a short lock + jittered TTLs.
Why caching, why now
After indexing my Postgres queries (previous post), reads were fast - but I was still hammering the database for the same "popular projects" list on every page load. That data changes maybe once an hour. Recomputing it thousands of times a minute is pure waste. Enter Redis.
Redis is more than a hashmap
What surprised me: Redis isn't just GET/SET. It ships real data structures, each with O(1) or O(log n) operations:
- Strings - counters, serialized JSON, cached HTML.
- Hashes - store an object's fields without serializing the whole thing.
- Sorted sets (ZSET) - leaderboards and rate limiters, sorted by score.
- Lists - simple queues.
- Sets - membership, deduplication, tag intersections.
Because it's single-threaded and in-memory, individual commands are atomic and blisteringly fast. That simplicity is a feature.
The cache-aside pattern
This is the workhorse. The application - not Redis - owns the logic:
async function getPopularProjects(): Promise<Project[]> {
const cached = await redis.get("projects:popular");
if (cached) return JSON.parse(cached); // hit
const projects = await db.project.findMany({ // miss
orderBy: { stars: "desc" },
take: 10,
});
// Backfill with a TTL so stale data self-heals
await redis.set("projects:popular", JSON.stringify(projects), "EX", 300);
return projects;
}The TTL (EX 300 = 5 minutes) is the safety net: even if I forget to invalidate, the cache can only be wrong for 5 minutes. For data where staleness is unacceptable, you pair this with explicit invalidation on write.
Eviction: what happens when memory fills up
Redis is memory-bound, so when maxmemory is hit it evicts keys based on a policy. The two I reach for:
allkeys-lru- evict least-recently-used keys. Good default for a pure cache.allkeys-lfu- least-frequently-used. Better when some keys are hot all day and others spike briefly.
Choosing the wrong policy (like noeviction) means writes start failing with OOM errors once you're full - which is exactly how I learned this.
The cache stampede that bit me
Here's the bug that taught me the most. My popular-projects key expired, and at that exact moment ~30 concurrent requests all got a cache miss, all queried Postgres simultaneously, and all tried to backfill. My dev DB connection pool exhausted instantly. This is a cache stampede (thundering herd).
Two fixes, used together:
- A short lock so only one request recomputes while others wait briefly or serve slightly stale data:
const lock = await redis.set("lock:projects:popular", "1", "NX", "EX", 10);
if (!lock) {
// Someone else is recomputing - return stale or retry after a beat
return staleOrRetry();
}- Jittered TTLs - instead of every related key expiring at the same second, add randomness (
300 + rand(0..60)) so expirations spread out and never align into a herd.
Persistence (the part people forget Redis has)
Redis can survive restarts via RDB (point-in-time snapshots) or AOF (append-only log of every write). For a pure cache I don't care - I can rebuild from Postgres. But knowing the difference matters the day you use Redis as a primary store.
Takeaways
- Always set a TTL. A cache without expiry is a memory leak that lies to users.
- Plan for the miss, not the hit. Stampedes happen at expiry, under load.
- Pick an eviction policy on purpose -
allkeys-lruis a sane default.
Next
My system is getting more pieces - API, cache, database. They're still tightly coupled and synchronous. I want to decouple them with events, which means it's finally time to learn Kafka.