Back to blog
Feb 18, 2023 • 4 min read

Building High-Throughput Microservices with Node.js and Redis

Node.jsRedisMicroservicesPerformance

Under normal loads, Node.js delivers impressive I/O concurrency. However, when traffic surges beyond 25,000 requests per second, microservices built without careful event-loop discipline begin to degrade. Event-loop lag spikes, database connection pools exhaust, garbage collection pauses freeze in-flight HTTP requests, and downstream services collapse from cascading thundering herds.

To build services capable of sustaining 50,000+ requests per second with sub-5ms p99 response times, architects must eliminate unnecessary context switches, batch network I/O with Redis pipelining, and prevent cache stampedes at the edge.


1. The Anatomy of Event Loop Lag Under High Concurrency

Node.js executes JavaScript on a single thread using the libuv event loop:

[Incoming HTTP Requests]
           |
           v
  +-------------------------------------------------------+
  |              libuv Event Loop Tick                    |
  |  Timers -> I/O Polling -> Check (setImmediate) -> GC  |
  +-------------------------------------------------------+
           |
     Is Event Loop Blocked?
     /                    \
   [No]                  [Yes] (JSON serialization of 5MB payload)
    |                     |
Sub-millisecond        Event Loop Lag Spikes (> 150ms)
Fast Response          Downstream HTTP Timeouts Cascade
  1. JSON Serialization Bottleneck: Calling JSON.stringify() on massive arrays blocks the main thread synchronously.
  2. Sequential Network RTTs: Fetching 10 keys sequentially across 10 individual Redis round-trips ($10 \times 1\text{ ms} = 10\text{ ms}$) unnecessarily stalls the client.
  3. Database Connection Pool Saturation: Direct relational database queries cannot sustain 50,000 concurrent active connections without proxy pooling.

2. Eliminating Network Round-Trips with Redis Pipelining

Standard Redis commands require a synchronous request-response network cycle. Redis Pipelining packs multiple commands into a single TCP socket buffer, slashing network latency to near zero:

import Redis from "ioredis";

export const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
  maxRetriesPerRequest: 3,
  enableAutoPipelining: true, // Automatically batches concurrent commands in the same tick!
  connectionName: "gateway-core",
});

interface UserProfile {
  id: string;
  name: string;
  role: string;
  cachedAt: number;
}

/**
 * Batches multi-key retrieval into a single TCP socket frame.
 * 100 individual keys fetched in 1 network round-trip instead of 100!
 */
export async function batchFetchUserProfiles(userIds: string[]): Promise<UserProfile[]> {
  const pipeline = redis.pipeline();

  for (const id of userIds) {
    pipeline.hgetall(`user:cache:${id}`);
  }

  // Executes all commands atomically across the wire
  const rawResults = await pipeline.exec();

  if (!rawResults) return [];

  return rawResults.map(([err, data], idx) => {
    if (err || !data || Object.keys(data).length === 0) {
      return { id: userIds[idx], name: "Unknown", role: "guest", cachedAt: 0 };
    }
    return data as unknown as UserProfile;
  });
}

3. Mitigating Cache Stampedes (Thundering Herd)

When a hot cache key expires (such as a breaking news article or live flash sale inventory), thousands of concurrent requests miss the cache simultaneously, bombarding the primary database with identical queries.

Implement the Probabilistic Early Expiration (XFetch) algorithm to refresh cache entries asynchronously before they expire:

interface CachePayload<T> {
  value: T;
  delta: number; // Duration taken to compute value (ms)
  expiry: number; // Unix timestamp
}

export async function getWithProbabilisticRefresh<T>(
  key: string,
  ttlSeconds: number,
  fetchFn: () => Promise<T>,
  beta: number = 1.0 // Aggressiveness factor (> 1 = earlier refresh)
): Promise<T> {
  const rawData = await redis.get(key);
  const now = Date.now();

  if (rawData) {
    const cached: CachePayload<T> = JSON.parse(rawData);
    
    // XFetch heuristic: -delta * beta * ln(random(0,1))
    const shouldRefreshEarly = (now - (cached.delta * beta * Math.log(Math.random()))) > cached.expiry;

    if (!shouldRefreshEarly) {
      return cached.value;
    }

    // Refresh asynchronously in background without blocking current user request
    refreshCacheInBackground(key, ttlSeconds, fetchFn).catch(console.error);
    return cached.value;
  }

  // Cache completely missing: compute immediately
  const startTime = Date.now();
  const value = await fetchFn();
  const delta = Date.now() - startTime;

  const payload: CachePayload<T> = {
    value,
    delta,
    expiry: now + (ttlSeconds * 1000)
  };

  await redis.setex(key, ttlSeconds, JSON.stringify(payload));
  return value;
}

async function refreshCacheInBackground<T>(key: string, ttl: number, fn: () => Promise<T>) {
  const start = Date.now();
  const fresh = await fn();
  const payload: CachePayload<T> = {
    value: fresh,
    delta: Date.now() - start,
    expiry: Date.now() + (ttl * 1000)
  };
  await redis.setex(key, ttl, JSON.stringify(payload));
}

4. Node.js Production Cluster Architecture

To fully utilize all CPU cores on a bare-metal server or multi-core cloud VM, run Node.js in cluster mode with tuned threadpool sizing:

// ecosystem.config.cjs
const os = require("os");

module.exports = {
  apps: [
    {
      name: "high-throughput-api",
      script: "./dist/server.js",
      instances: "max", // Matches os.cpus().length
      exec_mode: "cluster",
      max_memory_restart: "1200M",
      kill_timeout: 5000,
      env_production: {
        NODE_ENV: "production",
        // Increase libuv asynchronous crypto and compression threadpool size
        UV_THREADPOOL_SIZE: "64",
      }
    }
  ]
};

5. High-Load Benchmark Metrics

Stress-tested using k6 over a 5-minute sustained load window (4 vCPU, 8GB RAM host):

Architecture Tier Throughput (RPS) p50 Latency p99 Latency Error Rate
Naive Express (Single Core, No Cache) 2,800 req/s 38 ms 215 ms 1.8% (Timeouts)
Fastify Cluster (4 Workers, Direct DB) 12,200 req/s 11 ms 74 ms 0.2%
Fastify Cluster + Redis Pipelines 54,400 req/s 1.2 ms 3.8 ms 0.00%

Key Production Takeaways

  • Enable Auto-Pipelining: ioredis can automatically bundle operations invoked during the same tick into a single TCP frame.
  • Defend Against Cache Stampedes: Implement probabilistic early expiration so hot keys never experience simultaneous cache misses.
  • Tune UV_THREADPOOL_SIZE: Increase the default 4-thread limit when using intensive cryptographic operations or bcrypt hashing.