Back to Blog

Horizontal Scaling for NestJS — Moving From Single Server to Multiple Instances

Published: June 24, 2026
Horizontal Scaling for NestJS — Moving From Single Server to Multiple Instances

The traffic spike that took down the single server came at 2pm on a Tuesday. Not a product launch, not a marketing blast — just the normal growth we had been ignoring because the server was handling it. Until it wasn't.

Add a second server instance, and every assumption you built into that single-server architecture breaks. Users get logged out randomly because their session lives on instance A but their next request hit instance B. Cached data is inconsistent — instance A has the dashboard data cached at 340ms, instance B takes 2 seconds because it has to go back to the database. WebSocket connections are silently dropped when the load balancer routes the client to a different instance. Background jobs run twice because two instances picked up the same task.

This post covers the five things that break when you scale NestJS horizontally and the exact Redis-based fixes for each. NestJS horizontal scaling with Redis is a well-worn path — every problem has a known solution, and they all involve centralizing state that was previously living in process memory.

Close-up view of modern rack-mounted server units in a data center representing NestJS horizontal scaling architecture

What Breaks When You Add a Second Instance

Before the fixes, understand the pattern. NestJS applications are stateless in theory — the framework itself does not tie request handling to a specific process. But the libraries and patterns we build on top of it introduce state:

In-memory session data. Express stores sessions in process memory by default. Instance A knows about user 42's session. Instance B does not. The load balancer sends user 42's next request to instance B, and suddenly they are logged out.

In-memory caches. @nestjs/cache-manager with an in-memory store means each instance has its own cache. A cache miss on instance B means a database query that instance A already answered. Multiply that by every concurrent user and your database takes the hit you were trying to avoid.

WebSocket connections. Socket.IO binds a connected client to the specific instance that handled the initial handshake. Broadcasting a message from instance A only reaches clients connected to instance A. Clients on instance B never see it.

Background job workers. BullMQ workers registered in every instance all poll the same Redis queue. Without coordination, the same job can be processed by multiple workers simultaneously.

Load balancer health checks. Your Nginx or AWS ALB needs to know which instances are alive. Without a health check endpoint, the load balancer routes traffic to dead instances and users see 502 errors.

The fix for every single one of these is the same: move the state into Redis.

Fix 1: Externalize Session State to Redis

Express-session stores session data in memory by default. On a single server this is fine. Add a second server and the contract breaks.

The fix is connect-redis, which stores sessions in Redis so every instance reads from the same store:

TypeScript
1// main.ts
2import { NestFactory } from '@nestjs/core';
3import { AppModule } from './app.module';
4import * as session from 'express-session';
5import RedisStore from 'connect-redis';
6import { createClient } from 'redis';
7
8async function bootstrap() {
9  const app = await NestFactory.create(AppModule);
10
11  const redisClient = createClient({ url: process.env.REDIS_URL });
12  redisClient.on('error', (err) => console.error('Redis error', err));
13  await redisClient.connect();
14
15  app.use(
16    session({
17      store: new RedisStore({ client: redisClient }),
18      secret: process.env.SESSION_SECRET!,
19      resave: false,
20      saveUninitialized: false,
21      cookie: {
22        secure: process.env.NODE_ENV === 'production',
23        httpOnly: true,
24        maxAge: 30 * 60 * 1000, // 30 minutes
25      },
26    }),
27  );
28
29  await app.listen(3000);
30}
31bootstrap();

Each instance configures the same Redis URL. Sessions are written to Redis on login and read from Redis on every request. Instance A writes the session, instance B reads it. No sticky sessions required.

Alternatively, use JWT-based auth and skip server-side sessions entirely. JWTs are self-contained — the token carries the user's identity, and any instance can verify it without shared state. That is the cleaner approach and the one we use for most clients.

Fix 2: Externalize Caches to Redis

NestJS's CacheModule defaults to an in-memory store. On a single instance this is fine. On multiple instances each one has a separate cache, which means your effective cache hit rate drops as requests spray across instances.

Swap to the Redis store:

TypeScript
1// app.module.ts
2import { Module } from '@nestjs/common';
3import { CacheModule } from '@nestjs/cache-manager';
4import { redisStore } from 'cache-manager-redis-yet';
5
6@Module({
7  imports: [
8    CacheModule.registerAsync({
9      useFactory: async () => ({
10        store: await redisStore({
11          socket: {
12            host: process.env.REDIS_HOST || 'localhost',
13            port: parseInt(process.env.REDIS_PORT || '6379'),
14          },
15          ttl: 300_000, // 5 minute default
16        }),
17      }),
18    }),
19  ],
20})
21export class AppModule {}

Now a cache entry written by instance A is immediately available to instance B. The cache is no longer per-process — it is a shared resource. This also means cache invalidation is trivial: delete a key from any instance and all instances respect it.

The tradeoff is latency — Redis adds a network round-trip versus in-memory access (~1ms vs ~0.01ms). For most SaaS APIs the difference is negligible. For high-throughput endpoints where every microsecond matters, consider a two-tier cache: a small in-memory L1 cache (with short TTL) backed by Redis.

Fix 3: Share WebSocket State With Redis Adapter

Socket.IO stores connection state per-instance. When you scale to multiple instances, a message emitted on instance A never reaches clients connected to instance B. Users in the same "room" see different data depending on which server they landed on.

The @socket.io/redis-adapter solves this by using Redis Pub/Sub to broadcast messages across all instances:

TypeScript
1// main.ts
2import { NestFactory } from '@nestjs/core';
3import { AppModule } from './app.module';
4import { IoAdapter } from '@nestjs/platform-socket.io';
5import { createAdapter } from '@socket.io/redis-adapter';
6import { createClient } from 'redis';
7
8class RedisIoAdapter extends IoAdapter {
9  private adapterConstructor: ReturnType<typeof createAdapter>;
10
11  async connectToRedis(): Promise<void> {
12    const pubClient = createClient({ url: process.env.REDIS_URL });
13    const subClient = pubClient.duplicate();
14    await Promise.all([pubClient.connect(), subClient.connect()]);
15    this.adapterConstructor = createAdapter(pubClient, subClient);
16  }
17
18  createIOServer(port: number, options?: any): any {
19    const server = super.createIOServer(port, options);
20    server.adapter(this.adapterConstructor);
21    return server;
22  }
23}
24
25async function bootstrap() {
26  const app = await NestFactory.create(AppModule);
27  const redisAdapter = new RedisIoAdapter(app);
28  await redisAdapter.connectToRedis();
29  app.useWebSocketAdapter(redisAdapter);
30  await app.listen(3000);
31}

The Socket.IO Redis adapter documentation has the full setup. Every Socket.IO event — join room, leave room, broadcast — is published to Redis and received by every instance. Clients stay connected to their original instance, but messages flow across the cluster.

One caveat: the initial HTTP long-polling handshake requires sticky sessions at the load balancer level. Once the connection upgrades to WebSocket, sticky sessions are less critical, but the initial handshake still needs to reach the same instance.

Blue ethernet cables connected to a network switch representing network connectivity for NestJS horizontal scaling

Fix 4: Coordinate Background Job Workers

BullMQ workers, by default, process jobs as soon as they are available in the queue. When you run the same NestJS application on multiple instances, each instance registers identical workers. Without coordination, the same job can be processed by multiple workers.

BullMQ handles this correctly out of the box — Redis locks ensure each job is processed exactly once (or at least once in failure scenarios). But there is a subtle issue: if your worker processor uses in-memory state (a cache, a rate limiter, a connection pool), each instance has its own copy. One worker might be overloaded while another sits idle.

The fix is to configure workers with explicit concurrency limits and let BullMQ's Redis-based job distribution do the coordination:

TypeScript
1// task.processor.ts
2import { Processor, WorkerHost } from '@nestjs/bullmq';
3import { Job } from 'bullmq';
4
5@Processor('task-queue', {
6  concurrency: 10,
7})
8export class TaskProcessor extends WorkerHost {
9  async process(job: Job): Promise<any> {
10    // BullMQ guarantees each job is processed once
11    // Concurrent jobs are distributed across workers
12    const { taskId, payload } = job.data;
13    return this.processTask(taskId, payload);
14  }
15}

Each instance registers the same processor. BullMQ distributes jobs across all available workers using Redis. Set concurrency to match your instance's CPU capacity — typically 10-20 per instance for CPU-bound tasks, higher for I/O-bound tasks.

If you need to ensure that certain jobs run on specific instances (e.g., one instance handles email jobs while another handles image processing), use separate queues for each job type and run specific processors on specific instances. This is covered in detail in our background job queue architecture post.

Fix 5: Add Health Check Endpoints for the Load Balancer

Your load balancer (Nginx, AWS ALB, HAProxy) needs to know which instances are alive and accepting traffic. Without health checks, a crashed instance stays in the rotation and users see 502 errors.

Add a simple health check endpoint in NestJS:

TypeScript
1// health.controller.ts
2import { Controller, Get } from '@nestjs/common';
3import { HealthCheckService, HealthCheck } from '@nestjs/terminus';
4
5@Controller('health')
6export class HealthController {
7  constructor(private health: HealthCheckService) {}
8
9  @Get()
10  @HealthCheck()
11  check() {
12    return this.health.check([]);
13  }
14}

For a more thorough check, include database connectivity, Redis connectivity, and disk space. NestJS's @nestjs/terminus package provides built-in health indicators for common services.

Configure your load balancer to hit /health every 10-30 seconds. If it returns non-200, the load balancer removes that instance from rotation. When the instance recovers and returns 200 again, it is added back.

Nginx configuration for round-robin load balancing with health checks:

NGINX
1upstream nestjs_cluster {
2    server 10.0.0.1:3000;
3    server 10.0.0.2:3000;
4    server 10.0.0.3:3000;
5}
6
7server {
8    listen 80;
9    location / {
10        proxy_pass http://nestjs_cluster;
11        proxy_set_header Host $host;
12        proxy_set_header X-Real-IP $remote_addr;
13    }
14}

For Socket.IO apps, add IP-hash to keep clients on the same instance:

NGINX
1upstream nestjs_cluster {
2    ip_hash;
3    server 10.0.0.1:3000;
4    server 10.0.0.2:3000;
5    server 10.0.0.3:3000;
6}

Putting It Together: The Redis-Centric Architecture

Once all five fixes are applied, the architecture looks like this:

  • Nginx (or your cloud load balancer) distributes HTTP and WebSocket traffic across NestJS instances
  • Each instance reads/writes sessions from shared Redis via connect-redis
  • Each instance reads/writes cache data from shared Redis via cache-manager-redis-yet
  • Socket.IO instances broadcast events through the Redis Pub/Sub adapter
  • BullMQ workers coordinate job processing through Redis locks
  • Health check endpoints tell the load balancer which instances are alive

The nice thing about this pattern is that adding a sixth instance requires zero code changes. You deploy the same Docker image, register it with the load balancer, and Redis handles the coordination.

Zero-Downtime Deployments

Horizontal scaling and zero-downtime deployments go together. When you have multiple instances, you can deploy a new version by:

  1. Rolling out new instances alongside the old ones
  2. Waiting for health checks to pass on the new instances
  3. Removing old instances from the load balancer
  4. Terminating the old instances

NestJS's OnApplicationShutdown lifecycle hook helps here — it lets you gracefully close database connections, Redis connections, and active WebSocket handlers before the process exits:

TypeScript
1// app.module.ts
2import { Injectable, OnApplicationShutdown } from '@nestjs/common';
3
4@Injectable()
5export class AppService implements OnApplicationShutdown {
6  async onApplicationShutdown(signal: string) {
7    console.log(`Shutting down: ${signal}`);
8    // Close Redis connections, database pools, etc.
9  }
10}

The load balancer stops sending new requests to the shutting-down instance, existing requests finish, and the process exits cleanly. Users notice zero downtime.

What NestJS Horizontal Scaling Costs

Scaling is not free. Redis itself needs to be deployed — a managed Redis instance from your cloud provider or a self-hosted cluster. Each Redis call adds a network round trip. And the architecture is more complex than a single-server setup.

But the alternatives are worse. Sticky sessions couple clients to specific instances and complicate rolling deployments. In-memory state means every instance is a snowflake with its own view of the world. Background job duplication means inconsistent data and wasted compute.

For most SaaS products, the Redis-centric approach is the right one. The Redis caching strategy we use covers the cache-specific tuning — TTLs, eviction policies, and stampede prevention. This article covers the broader architecture that makes those Redis choices matter.

Start with one server and a single Redis instance. When your load balancer starts distributing requests to a second server, the five fixes above will be waiting for you. They take an afternoon to implement and they solve every problem that second server creates.

The server that went down at 2pm on a Tuesday is now three servers behind a load balancer, all sharing state through Redis, with health checks, zero-downtime deploys, and enough headroom that nobody thinks about the Tuesday spike anymore.

Focused view of a modern data server rack with blinking lights representing running production infrastructure for NestJS horizontal scaling

Frequently Asked Questions

Four things break immediately: in-memory session state (users get logged out randomly as requests hit different instances), in-memory caches (each server has its own cached data, so a cache hit on one instance is a miss on another), WebSocket connections (users are bound to the instance they connected to, so messages sent from other instances never reach them), and background job workers (the same job may run on multiple instances simultaneously, causing duplicate processing). All four problems are solved by externalizing shared state to Redis.

Use connect-redis with express-session to store session data in Redis instead of process memory. NestJS runs on Express, so standard Express session middleware works natively. Configure RedisStore in main.ts with your Redis connection, and sessions become available to every instance. Users can be routed to any server without losing their session. For token-based auth (JWT), the token is self-contained so sessions are not an issue at all — which is why JWT is often preferred for horizontally scaled apps.

Use the @socket.io/redis-adapter package. Install it alongside ioredis, create a pub/sub Redis client pair in main.ts, and pass the adapter to the Socket.IO server. The adapter uses Redis Pub/Sub to broadcast messages from any server instance to all connected clients, regardless of which instance they are connected to. Without this adapter, a message emitted on instance A never reaches clients connected to instance B. You still need sticky sessions at the load balancer level for the initial HTTP long-polling upgrade.

Nginx is the most common choice for NestJS horizontal scaling. It supports round-robin, least-connections, and IP-hash load balancing algorithms. For Socket.IO apps, use IP-hash (sticky sessions) to ensure WebSocket connections stay on the same instance. AWS ALB and HAProxy are also excellent options. The load balancer should also terminate TLS and pass health check endpoints to detect unhealthy instances. Configure health checks at /api/health returning a 200 status for the load balancer to route traffic to.

It depends on your auth strategy. If you use JWT (stateless tokens stored in the client), sticky sessions are not needed — any instance can verify the token independently. If you use server-side sessions stored in Redis, sticky sessions are also optional because the session data lives in Redis, not in process memory. Sticky sessions matter primarily for WebSocket connections: Socket.IO's HTTP long-polling transport requires the client to reach the same server for subsequent requests. Once the WebSocket upgrade completes, sticky sessions are less critical but still recommended.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio