TutorialsRedisPerformance

Redis caching patterns that work in production

Cache-aside, TTL discipline, and stampede prevention, with managed Redis on the private network and zero egress fees.

PP

Priya Patel

Head of Engineering

April 12, 20268 min read

Adding Redis does not automatically make your app fast. Bad cache keys, missing TTLs, and thundering herds can make things worse than hitting Postgres directly. This post covers the patterns we see work consistently on StackBlaze production workloads.

Provision Redis on the private network

blueprint.yaml
services:
  api:
    type: web
    build: .
    env:
      REDIS_URL:
        from_service: cache
        property: connection_string

  cache:
    type: redis
    version: "7"
    plan: standard

Traffic between api and cache stays on the private fabric, sub-millisecond latency and no egress charges. Use the .internal hostname in logs if you debug connection issues.

Cache-aside (lazy loading)

lib/cache.ts
import Redis from 'ioredis';

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

export async function getUser(id: string) {
  const key = `user:${id}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  if (!user) return null;

  await redis.set(key, JSON.stringify(user), 'EX', 300);
  return user;
}

Always set a TTL. Infinite keys turn Redis into a leakier second database with no schema. Five minutes is a reasonable default for user profile data; adjust based on how stale your UI can tolerate.

Preventing cache stampedes

When a hot key expires, every request may miss cache simultaneously and hammer your database. Mitigate with probabilistic early expiration, request coalescing, or a short-lived lock while one worker repopulates the key.

lib/cache-with-lock.ts
export async function getWithLock(key: string, loader: () => Promise<string>) {
  const hit = await redis.get(key);
  if (hit) return hit;

  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, '1', 'EX', 10, 'NX');
  if (!acquired) {
    await sleep(50);
    return getWithLock(key, loader);
  }

  try {
    const value = await loader();
    await redis.set(key, value, 'EX', 300);
    return value;
  } finally {
    await redis.del(lockKey);
  }
}

Invalidation strategies

  • Delete on write: simplest, when a user updates, DEL user:{id}
  • Versioned keys: user:{id}:v{version} avoids races during updates
  • Tag-based purge: store SET tags:org:{id} → [keys] for bulk invalidation

Do not cache what you cannot afford to lose

Sessions and rate-limit counters are fine in Redis. Financial balances and inventory counts should live in Postgres with Redis as an optional acceleration layer, not the system of record.

What to monitor

Watch hit rate (cache hits / total reads), memory usage, and evicted_keys. A dropping hit rate after a deploy often means a key format change. Rising evictions mean you need a larger plan or shorter TTLs.

Redis on StackBlaze includes persistence options on Standard plans and above. For pure cache workloads, disable AOF if you prefer speed over durability, you can always rebuild from Postgres.

PP

Priya Patel

Head of Engineering at StackBlaze

Member of the founding team at StackBlaze. Writes about infrastructure, engineering culture, and the systems that keep production running.

More from the blog