typescript 130 lines · 3 tabs

Redis cache-aside for expensive reads

Shared by codesnips Jan 2026
3 tabs
import Redis from "ioredis";

export const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");

export interface Codec<T> {
  encode(value: T): string;
  decode(raw: string): T;
}

export const jsonCodec = <T>(): Codec<T> => ({
  encode: (value) => JSON.stringify(value),
  decode: (raw) => JSON.parse(raw) as T,
});

const inflight = new Map<string, Promise<unknown>>();

function jitteredTtl(ttlSeconds: number): number {
  const spread = Math.ceil(ttlSeconds * 0.1);
  return ttlSeconds + Math.floor(Math.random() * spread);
}

export async function cached<T>(
  key: string,
  ttlSeconds: number,
  codec: Codec<T>,
  producer: () => Promise<T>
): Promise<T> {
  const hit = await redis.get(key);
  if (hit !== null) {
    return codec.decode(hit);
  }

  const existing = inflight.get(key) as Promise<T> | undefined;
  if (existing) {
    return existing;
  }

  const work = (async () => {
    const value = await producer();
    await redis.set(key, codec.encode(value), "EX", jitteredTtl(ttlSeconds));
    return value;
  })();

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

export async function invalidate(key: string): Promise<void> {
  await redis.del(key);
}
3 files · typescript Explain with highlit

The cache-aside (lazy-loading) pattern keeps expensive computations out of the hot path by checking a fast store first and only falling back to the authoritative source on a miss. This snippet builds a small, type-safe caching layer around ioredis and wires it into an Express controller that serves an expensive product-detail read.

In cache.ts, cached is the core primitive. It serializes and deserializes values through a caller-supplied Codec so the cache stays generic while the callers stay strongly typed. On a hit it returns the parsed value; on a miss it computes the value with producer, writes it back with a jittered TTL (ttlSeconds plus a random offset), and returns it. The TTL jitter matters: if thousands of keys are written in the same second with an identical TTL, they all expire together and cause a synchronized flood of recomputation. Spreading expiry over a window smooths that out.

The more subtle problem is the cache stampede — when a popular key expires and many concurrent requests miss at once, they all run the expensive producer simultaneously. cache.ts addresses this with a per-process single-flight map (inflight): the first caller for a key stores its in-progress Promise, and later callers within the same process await that same promise instead of launching duplicate work. SETNX-style locking would be needed to coordinate across processes, but in-process coalescing removes the bulk of the duplication cheaply.

ProductRepository shows a realistic collaborator: findDetail wraps a slow join-and-aggregate query behind cache, using a versioned key (product:v1:...) so the key namespace can be bumped to invalidate everything at once. invalidate deletes a single key after a write so the next read repopulates it.

productController.ts is the thin HTTP edge. getProduct simply calls the repository and maps a null to a 404, while updateProduct writes then calls invalidate to keep the cache honest. This layering — controller, repository, cache primitive — keeps caching concerns out of both the transport and the domain query, which is exactly when this pattern is worth reaching for.


Related snips

Share this code

Here's the card — post it anywhere.

Redis cache-aside for expensive reads — share card
Link copied