This episode covers distributing WebSocket connections across many servers: the sticky session challenge, the difference between Layer 4 and Layer 7 load balancers, NGINX and HAProxy configuration, health checks, and graceful shutdown.

A single Node.js server can only serve tens of thousands of connections. As the application grows, you add servers — but WebSocket connections are different from ordinary HTTP requests. A WebSocket connection lives a long time, so once it is thrown to a server, it must stay on that server forever.
Episode 15 covers load balancing for WebSocket: why sticky sessions are needed, the difference between Layer 4 and Layer 7 load balancers, NGINX and HAProxy configuration, and how to keep servers healthy during deployment.
An HTTP request finishes in milliseconds, so it can be thrown at any server. A WebSocket connection lasts for hours. If the load balancer sends the upgrade request to server A but the next frames to server B, the connection breaks.
curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
http://localhost:8080The command above sends an upgrade handshake. The load balancer must recognize this request and direct the entire connection to a single backend from handshake to close.
The key is sticky session or affinity: all frames from one connection go to the same server.
Without this, a stateful application like chat breaks: messages are scattered across many servers.
Load balancers work at two different layers.
Layer 4 (TCP) : melihat alamat IP dan port, memforward byte mentah
Layer 7 (HTTP) : melihat isi request, bisa baca header UpgradeLayer 4 is very fast and does not care about the protocol — it just forwards bytes. Unfortunately it cannot read the Upgrade header, so it cannot guarantee per-connection sticky sessions.
Layer 7 reads HTTP headers and understands the WebSocket handshake. It can set cookie- or header-based affinity, but it costs a bit more because it must parse the request.
NGINX is the most common choice for WebSocket in production because its configuration is simple.
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}The two most important headers: proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade". Without them, the WebSocket connection fails immediately because NGINX does not forward the upgrade request. The 3600-second timeout prevents NGINX from cutting healthy idle connections.
For multiple backends, define an upstream with the sticky option.
upstream ws_backend {
ip_hash;
server 10.0.1.1:8080;
server 10.0.1.2:8080;
}
server {
listen 80;
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}ip_hash consistently routes clients with the same IP to the same backend. For a more even distribution, use the cookie-based sticky cookie option.
HAProxy is known to be very efficient for persistent connections like WebSocket.
frontend ws_front
bind *:80
mode http
use_backend ws_back if { hdr(Upgrade) -i websocket }
backend ws_back
mode http
server node1 10.0.1.1:8080 weight 1
server node2 10.0.1.2:8080 weight 1The condition { hdr(Upgrade) -i websocket } routes requests carrying the Upgrade header to the dedicated WebSocket backend. HAProxy handles WebSocket connections natively without extra configuration, as long as the timeout client and timeout server values are set long (for example 3600 seconds) so connections living longer than an hour are not cut.
The load balancer needs to know which servers are healthy. For WebSocket, the health check should be in the application, not just an open port.
const http = require("http");
const server = http.createServer((req, res) => {
if (req.url === "/healthz") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "ok",
koneksi: wss.clients.size,
}));
return;
}
res.writeHead(404);
res.end();
});The /healthz endpoint reports the server status and the number of active connections. The load balancer marks a backend healthy only if this endpoint responds with 200.
When deploying, the old server must release connections gently, not be killed outright.
process.on("SIGTERM", () => {
wss.clients.forEach((ws) => {
ws.close(1001, "server akan di-deploy");
});
server.close(() => {
console.log("semua koneksi ditutup, server berhenti");
process.exit(0);
});
setTimeout(() => process.exit(1), 10000).unref();
});Code 1001 means going away. A healthy load balancer detects the outgoing connections and routes clients to the remaining backends, while clients with the reconnection logic from episode 12 move without the user noticing.
Episode 15 explained why WebSocket load balancing is more complex than ordinary HTTP: long-lived connections demand sticky sessions, understanding of the Upgrade header, and special treatment in the load balancer.
Key takeaways:
In the next episode we cover horizontal scaling with the Redis adapter: cross-server pub-sub, room synchronization, and a chat architecture serving thousands of servers at once.