Developments

Fixing the Thundering Herd: How Cache Stampede Almost Took Down My Node.js API

Efraim Ray Efraim Ray 0 Comments
Developer Notes & Solution

In this developer note, we investigate the cache stampede problem (aka thundering herd) that took our Node.js API from 120ms to 8.4s p99 latency under a traffic spike, and walk through the deterministic single-flight lock pattern that reduced database load by 94%.

Environment Node 20 / Redis 7.2
Scope / Type Cache Layer Redesign
Time to Fix ~35 min hotfix
Affected Stack Express / Postgres
Severity Critical (P0 Outage)
Node.js and Redis Cache Layer Architecture
Performance Workbench

The Problem & Root Cause Analysis

At 14:02 UTC, our flagship API endpoint GET /api/v1/dashboard began returning 504s to 12% of clients. CPU on the Node process was only 34%, Postgres was at 61% — nothing looked overloaded. Yet p99 latency climbed from 120ms to 8,400ms within 90 seconds.

The Symptom

Every 60 seconds, our Redis cache key dashboard:tenant:* expired simultaneously. Hundreds of incoming requests all saw a cache miss at the exact same millisecond, and every single one fired an expensive 1.8s Postgres aggregation query in parallel — overwhelming the DB connection pool.

The Root Cause

Classic cache stampede (thundering herd). Our naive cache layer had no single-flight guard. When N requests arrive on a cache miss, all N hit the database — but only 1 query is actually needed. We needed a lock so only 1 request refills the cache while the others wait.

api_gateway.log • PROD • 14:02:47Z
[2026-09-05T14:02:47.212Z] WARN: Redis GET latency spike: 4,210ms (baseline: 0.8ms) [2026-09-05T14:02:47.418Z] INFO: Cache miss for key dashboard:tenant:4821 — 342 concurrent misses in 3s window [2026-09-05T14:02:48.001Z] ERROR: pg: Pool exhausted. Client cannot acquire connection within 5000ms. --> at Pool.acquire (/app/node_modules/pg-pool/index.js:78) --> at getDashboardMetrics (/app/src/services/dashboard.ts:214:14) --> at async cacheWrapper (/app/src/lib/cache.ts:41:18) FATAL: 504 Gateway Timeout — 342 requests failed in this batch.

Before vs. After: The Code Fix

Here's the exact side-by-side diff that eliminated the stampede. The "before" version blindly refills the cache on every miss. The "after" version uses a Redis SET NX lock so only one request refills while the others wait briefly.

BEFORE (Naive Cache) cache.ts
// ❌ Every miss = 1 DB query. N misses = N queries.
export async function cached<T>(
  key: string,
  ttl: number,
  fetcher: () => Promise<T>
): Promise<T> {
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);

  // 💥 All concurrent callers land here at once
  const fresh = await fetcher();
  await redis.set(key, JSON.stringify(fresh), 'EX', ttl);
  return fresh;
}
AFTER (Single-Flight Lock) cache.ts
// ✅ Only 1 caller fetches. Others wait for the result.
export async function cached<T>(
  key: string,
  ttl: number,
  fetcher: () => Promise<T>
): Promise<T> {
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);

  const lockKey = `${key}:lock`;
  const acquired = await redis.set(
    lockKey, '1', 'EX', 10, 'NX'
  );

  if (!acquired) {
    // Wait 50ms and retry — cache will be warm soon
    await new Promise(r => setTimeout(r, 50));
    return cached(key, ttl, fetcher);
  }

  try {
    const fresh = await fetcher();
    // Jittered TTL to prevent future simultaneous expiry
    const jitteredTtl = ttl + Math.floor(Math.random() * 30);
    await redis.set(key, JSON.stringify(fresh), 'EX', jitteredTtl);
    return fresh;
  } finally {
    await redis.del(lockKey);
  }
}

Complete Implementation & Config

Full production-ready implementation including the Redis client, the cache wrapper, and the Docker Compose setup for local testing:

import { redis } from './redis.config';

interface CachedOptions {
  ttl: number;
  lockTtl?: number;
  retryDelayMs?: number;
  jitterMax?: number;
}

export async function cached<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: CachedOptions
): Promise<T> {
  const {
    ttl,
    lockTtl = 10,
    retryDelayMs = 50,
    jitterMax = 30,
  } = options;

  // 1. Try hot path
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit) as T;

  // 2. Try to acquire single-flight lock
  const lockKey = `${key}:lock`;
  const acquired = await redis.set(lockKey, '1', 'EX', lockTtl, 'NX');

  if (!acquired) {
    // 3. Someone else is fetching. Wait and retry.
    await new Promise(r => setTimeout(r, retryDelayMs));
    return cached(key, fetcher, options);
  }

  try {
    // 4. We own the lock. Fetch fresh data.
    const fresh = await fetcher();
    const jitteredTtl = ttl + Math.floor(Math.random() * jitterMax);
    await redis.set(key, JSON.stringify(fresh), 'EX', jitteredTtl);
    return fresh;
  } finally {
    await redis.del(lockKey);
  }
}
import Redis from 'ioredis';

export const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD,
  db: 0,
  maxRetriesPerRequest: 3,
  enableReadyCheck: true,
  retryStrategy(times) {
    const delay = Math.min(times * 50, 2000);
    return delay;
  },
  reconnectOnError(err) {
    const targetErrors = ['READONLY', 'ECONNRESET'];
    return targetErrors.some(e => err.message.includes(e));
  },
});

redis.on('connect', () => console.log('[Redis] Connected'));
redis.on('error', (err) => console.error('[Redis] Error:', err.message));
redis.on('reconnecting', () => console.warn('[Redis] Reconnecting...'));
version: '3.8'

services:
  redis:
    image: redis:7.2-alpine
    container_name: dev-redis
    restart: always
    ports:
      - "6379:6379"
    command: >
      redis-server
      --maxmemory 256mb
      --maxmemory-policy allkeys-lru
      --save ""
      --appendonly no
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  postgres:
    image: postgres:16-alpine
    container_name: dev-postgres
    restart: always
    environment:
      POSTGRES_USER: dev_admin
      POSTGRES_PASSWORD: dev_secure_password
      POSTGRES_DB: app_database
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Step-by-Step Patch & Verification Workflow

Follow these four steps to reproduce, patch, and verify the fix in staging before rolling to production:

1

Step 1: Reproduce with a Concurrent Load Test

Use autocannon with 200 concurrent connections against a cold cache. Watch the DB connection pool saturate while CPU stays idle. This is the stampede signature.

2

Step 2: Wrap the Cache with a Single-Flight Lock

Replace raw get → miss → set logic with the cached() helper above. The Redis SET NX command ensures only one worker runs the expensive query.

3

Step 3: Add TTL Jitter

Never set a fixed TTL. Add + Math.random() * 30 seconds of jitter so multiple cache keys don't expire at the same instant, avoiding the next stampede window.

4

Step 4: Verify Under Load

Re-run the load test. Confirm only one query fires per cache miss (check your DB logs) and p99 latency returns under 200ms.

Developer News & Release Highlights

Related ecosystem updates worth knowing if you're building cache-heavy services in 2026:

New Feature
Redis 7.4 — Native Client-Side Caching

Redis now ships a client-side cache that stores hot keys directly in your Node process memory, reducing round trips for read-heavy endpoints by up to 70%.

Breaking
ioredis v6 Drops Legacy Callbacks

Callback-style redis.get(key, (err, val) => ...) is fully removed. All APIs are Promise-only. Update any legacy code paths before upgrading.

Performance
Node 22 — AsyncLocalStorage 3x Faster

Context propagation across async boundaries is now 3x cheaper, making it viable for distributed tracing inside high-frequency cache wrappers.

Terminal Cheatsheet & Diagnostic Commands

Keep these one-liners handy when debugging Redis + Postgres performance under load:

$redis-cli INFO stats | grep hit_rate
$redis-cli --scan --pattern "dashboard:*:lock"
$npx autocannon -c 200 -d 30 http://localhost:3000/api/v1/dashboard
$psql -c "SELECT count(*) FROM pg_stat_activity WHERE state = 'active';"

Architecture & Profiling Visuals

Redis Performance Dashboard
Telemetry

Before: 342 Concurrent DB Queries

Postgres saw 342 identical queries fire within 3 seconds of the cache miss.

Cache Layer Topology
Topology

After: Single-Flight Lock Architecture

Only 1 request reaches Postgres. The rest await the warm cache.

Node.js Test Suite
Verification

Load Test Comparison Suite

Autocannon running against cold cache before and after the fix.

Developer Pro-Tip

Never set a fixed TTL on your cache keys. Always add jitter (e.g., ttl + Math.random() * 30 seconds). This spreads cache expiry across a window instead of a single millisecond, preventing the next stampede before it happens.

Source Code, Docs & Benchmarks

GitHub Example Repo

Full reproduction sandbox with Node, Redis, Postgres, and Autocannon load scripts ready to run.

View on GitHub

ioredis Docs

Reference for SET NX EX, lock patterns, and Redis client configuration for production Node apps.

Read Docs

Developer Discussions

Hit a stampede in a different stack? Share your scenario and let's compare fixes.

Join Discussion
Developer Takeaway

One Lock Beat a Whole Team of Retries

Adding a single Redis SET NX lock cut our database load by 94% and pulled p99 latency from 8,400ms down to 180ms — without changing a single line of business logic. Deterministic concurrency control beats "just add more servers" every time.

Efraim Ray
Written by

Efraim Ray

Software Developer and AI Specialist. BS Information Technology graduate. I pride myself on being conscientious, attentive to detail, and always open to learning — because the best solutions come from listening, taking notes, and exploring what's next.

Comments