All posts
ScalingJun 4, 2026·8 min read

Scaling Node.js with Redis and Load Balancers

Running more than one Node.js process means shared state is a problem. Sessions, rate limiting, caches, pub/sub, queues — here's the Redis-powered architecture that makes horizontal scaling work.

Why single-process Node.js hits a wall

Node is single-threaded. One process uses one CPU core. A 32-core server running a single Node process is wasting 31 cores. The solution is horizontal scaling — multiple processes — which immediately surfaces the shared state problem.

What can't be in-process anymore

The moment you run more than one instance:

  • Sessions — Request 1 hits server A (session created), Request 2 hits server B (no session). Broken.
  • Rate limiting — Each server tracks its own count. A user can exceed limits by round-robining requests.
  • In-memory cache — Each server caches independently. Cache invalidation on one doesn't affect others.
  • WebSocket rooms / presence — User A connects to server 1, user B to server 2. They can't communicate.

Redis solves all of these.

Session storage with Redis

import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

app.use(session({
  store: new RedisStore({ client: redis }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, maxAge: 86400000 },
}));

Distributed rate limiting

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '1 m'),
});

app.use(async (req, res, next) => {
  const { success, limit, remaining } = await ratelimit.limit(req.ip);
  res.setHeader('X-RateLimit-Limit', limit);
  res.setHeader('X-RateLimit-Remaining', remaining);
  if (!success) return res.status(429).json({ error: 'Too many requests' });
  next();
});

Caching with Redis

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

  const user = await db.user.findUnique({ where: { id: userId } });
  await redis.setex(`user:${userId}`, 300, JSON.stringify(user)); // 5min TTL
  return user;
}

// Invalidate on update
async function updateUser(userId: string, data: object) {
  await db.user.update({ where: { id: userId }, data });
  await redis.del(`user:${userId}`);
}

Pub/Sub for WebSockets

Socket.IO natively supports Redis pub/sub via the @socket.io/redis-adapter. This lets WebSocket events broadcast across all server instances so users connected to different servers can communicate.

import { createAdapter } from '@socket.io/redis-adapter';

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));

Load balancer configuration

For REST APIs: use round-robin. With session-based auth and a Redis session store, sticky sessions are not required.

For WebSockets: use IP-hash or cookie-based persistence in the load balancer. WebSocket connections are long-lived; if reconnected to a different server, the client re-establishes state but the session should still be valid.

Redis connection management

Create one shared Redis client per process (not per request). Use connection pooling for high-concurrency apps. Monitor connected clients — Redis has a default max of 10,000 but each idle connection uses ~1KB of memory.

Ready to put this into practice?

Deploy your Node.js app to production in minutes — zero YAML, automatic CI/CD, and HTTPS included.