All articles
Engineering#redis#caching#performance#node.js

Caching with Redis: cache-aside, stampede protection and invalidation

A cache is easy to add and hard to get right. This guide covers the cache-aside pattern, how to stop thundering herds, and invalidation strategies that keep data fresh without guesswork.

FX

Codestria Engineering

Engineering team

6 min read

Adding Redis in front of a slow query is one of the highest-leverage performance changes you can make. It is also one of the easiest ways to introduce subtle bugs: stale prices, users seeing each other's data, or a cache expiry that knocks over your database at the worst possible moment.

Caching is a correctness decision disguised as a performance decision. Before writing any code, you should be able to answer three questions for every cached value: how stale can it be, who invalidates it, and what happens when the cache is empty or unavailable. This article works through the patterns that answer those questions well.

Decide what is worth caching

Not every slow thing should be cached. Good candidates are read-heavy, expensive to compute and tolerant of some staleness: product catalogues, configuration, aggregated dashboards, rendered fragments and responses from rate-limited third-party APIs. Poor candidates are values that must be exact at every read, such as account balances or inventory at checkout, and data that is cheap to fetch with a well-indexed query.

Cache-aside: the default pattern

In cache-aside (also called lazy loading), the application owns the logic. On a read it checks Redis; on a miss it loads from the database, writes the result to Redis with a TTL, and returns it. On a write it updates the database and then deletes the cached key. The cache never talks to the database directly, which keeps it simple and makes Redis optional: if it goes down, reads fall through to the source of truth.

src/lib/cache.ts
import Redis from "ioredis";

export const redis = new Redis(process.env.REDIS_URL!);

export async function cached<T>(
  key: string,
  ttlSeconds: number,
  load: () => Promise<T>,
): Promise<T> {
  try {
    const hit = await redis.get(key);
    if (hit !== null) return JSON.parse(hit) as T;
  } catch {
    // Redis unavailable: degrade to the source of truth rather than failing the request.
    return load();
  }

  const value = await load();
  // Add jitter so keys written together do not all expire together.
  const ttl = Math.round(ttlSeconds * (0.9 + Math.random() * 0.2));
  await redis.set(key, JSON.stringify(value), "EX", ttl).catch(() => {});
  return value;
}

// Usage
export const getProduct = (id: string) =>
  cached(`product:v1:${id}`, 300, () => db.product.findUnique({ where: { id } }));

A few choices here are deliberate. The key includes a version segment (v1) so that a change to the cached shape can be rolled out by bumping the version rather than flushing Redis. The TTL has jitter so a batch of keys populated at the same moment does not expire in the same second. And Redis errors are swallowed on the read path, because a cache outage should make things slower, not broken.

Write-through and write-behind

In write-through, every write updates both the database and the cache. It keeps hot keys warm, but it caches data that may never be read and couples every write path to the cache. Write-behind writes to the cache first and flushes to the database asynchronously; it is fast but risks data loss and is rarely the right choice for business data. Cache-aside with delete-on-write is the safest default.

Stampede protection

A cache stampede (or thundering herd) happens when a popular key expires and many concurrent requests miss at once. Each one runs the expensive query, the database saturates, latency climbs, more requests pile up, and a single expiry turns into an incident. There are three complementary defences.

1. Request coalescing within a process

Inside one Node.js process, keep a map of in-flight loads. If a second request misses on the same key while the first is still loading, it awaits the same promise. This costs a few lines of code and removes duplicate work per instance.

2. A distributed lock across instances

Across many instances, use a short-lived Redis lock so only one worker recomputes a key. Others either wait briefly and re-read, or serve a stale copy if you keep one.

src/lib/cache-locked.ts
import { randomUUID } from "node:crypto";
import { redis } from "./cache";

const inflight = new Map<string, Promise<unknown>>();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

const RELEASE_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
end
return 0`;

export async function cachedWithLock<T>(
  key: string,
  ttlSeconds: number,
  load: () => Promise<T>,
): Promise<T> {
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit) as T;

  // Coalesce concurrent misses in this process.
  const existing = inflight.get(key);
  if (existing) return existing as Promise<T>;

  const promise = (async () => {
    const lockKey = `lock:${key}`;
    const token = randomUUID();

    for (let attempt = 0; attempt < 50; attempt++) {
      // NX: only set if absent. PX: auto-expire so a crashed holder cannot block forever.
      const acquired = await redis.set(lockKey, token, "PX", 5000, "NX");
      if (acquired) {
        try {
          const value = await load();
          await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
          return value;
        } finally {
          await redis.eval(RELEASE_SCRIPT, 1, lockKey, token);
        }
      }
      await sleep(100);
      const filled = await redis.get(key);
      if (filled !== null) return JSON.parse(filled) as T;
    }
    // Give up waiting and load directly rather than failing the request.
    return load();
  })();

  inflight.set(key, promise);
  try {
    return await promise;
  } finally {
    inflight.delete(key);
  }
}

The lock value is a random token, and release uses a small Lua script that deletes the lock only if the token still matches. Without that check, a worker whose lock already expired could delete a lock now held by someone else.

3. Serve stale while revalidating

The most user-friendly option is to never let hot keys go fully cold. Store the value with a soft expiry inside the payload and a longer hard TTL in Redis. When a read finds the soft expiry has passed, it returns the stale value immediately and triggers a single background refresh, guarded by the same lock. Users never wait on a recompute, and the database sees one refresh per key per interval.

These techniques combine well. Coalescing removes duplicate work inside each instance for free, the lock removes it across instances, and stale-while-revalidate removes user-facing latency for the keys that matter most. Start with jitter and coalescing everywhere, then add the lock and soft expiry to the handful of keys that are both hot and expensive.

Also consider caching negative results. If a lookup for a missing product or an unknown API key always misses the cache and hits the database, an attacker or a buggy client can bypass your cache entirely. Storing a short-lived not found marker closes that gap.

Invalidation strategies that hold up

Invalidation is where most caching bugs live. The goal is to make the rules explicit and boring.

  • TTL only. Simplest and often enough. Choose the TTL from the business answer to how stale can this be, not from what feels fast.
  • Delete on write. After the database transaction commits, delete the affected keys. Delete rather than overwrite, so the next read loads fresh data and a race between two writers cannot leave an old value behind.
  • Versioned keys. Store a version counter per entity or per tenant and include it in the key. Bumping the counter invalidates every derived key at once without scanning Redis.
  • Event-driven invalidation. When several services cache the same data, publish change events (ideally through an outbox) and let each service invalidate its own keys.
TypeScript
// Delete after commit, never before. Deleting first lets a concurrent read
// repopulate the cache with the old row before the transaction commits.
await db.$transaction(async (tx) => {
  await tx.product.update({ where: { id }, data: { price } });
});
await redis.del(`product:v1:${id}`);

// Versioned keys: invalidate everything derived from a tenant's catalogue.
const version = (await redis.get(`catalogue-version:${tenantId}`)) ?? "0";
const listKey = `catalogue:${tenantId}:v${version}:page:${page}`;
// On any catalogue change:
await redis.incr(`catalogue-version:${tenantId}`);

Operational details that matter

  • Set `maxmemory` and an eviction policy. For a pure cache, allkeys-lru or allkeys-lfu lets Redis evict cold keys instead of rejecting writes.
  • Always set a TTL. A key without expiry is a memory leak waiting to happen.
  • Keep values small. Large JSON blobs increase network time and serialisation cost. Cache the fields you need, not the whole object graph.
  • Namespace keys by domain, version and tenant, for example product:v1:{id}. It makes debugging and selective invalidation far easier.
  • Never cache per-user data under a shared key. Include the user or tenant id in the key whenever the value depends on who is asking.
  • Track hit rate and latency. A cache with a low hit rate adds a network hop for little benefit.

Key takeaways

  • Decide staleness, ownership of invalidation and fallback behaviour before writing code.
  • Use cache-aside with delete-after-commit as the default; make Redis failures degrade gracefully.
  • Add TTL jitter, request coalescing and a token-guarded lock to prevent stampedes.
  • Serve stale while revalidating for hot keys so users never wait on a recompute.
  • Prefer versioned keys and explicit deletes over pattern scans.

Need help designing a caching layer or diagnosing a slow API? Our backend engineering work covers Redis, Postgres tuning and queue-based architectures. Start a conversation.

Found this useful? Share it with your team.

LinkedInX

Need help with backend & apis?

Secure, scalable backends and APIs with Node.js, Nest.js and Python, built for real traffic.

Learn more