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.