Skip to content
Available for work
Adam
←  All posts

Redis and the Art of Caching Without Lying to Your Users

Cache-aside, TTLs, eviction policies, and the cache stampede that took down my dev environment until I learned about locks.

4 min readredis · caching · performance

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:

  1. 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();
}
  1. 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-lru is 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.