How to Use Nginx as a WebSocket Load Balancer

How to Use Nginx as a WebSocket Load Balancer

How to Use Nginx as a WebSocket Load Balancer

WebSockets are a different animal from regular HTTP traffic. A normal HTTP request comes in, gets a response, and the connection closes (or gets reused briefly with keep-alive). A WebSocket connection, on the other hand, starts as an HTTP request but then upgrades into a long-lived, bidirectional TCP connection that can stay open for hours. If you’re running a chat app, a live dashboard, a multiplayer game backend, or anything with real-time notifications, you’ve probably run into the question of how to scale that across multiple backend servers — and that’s exactly what I want to walk through here.

I’ve set this up for a couple of real-time products now, and the core challenge is always the same: WebSockets need “sticky” behavior in a lot of architectures, and the proxy sitting in front has to correctly handle the HTTP Upgrade handshake without messing with the timeouts. Nginx handles all of this well once it’s configured correctly, but the defaults will bite you if you don’t know what to look for.

How WebSockets Work (Briefly)

A WebSocket connection begins life as a normal HTTP GET request with special headers:

GE T /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

If the server agrees, it responds with 101 Switching Protocols, and from that point on, the same TCP connection is used for full-duplex WebSocket frames instead of regular HTTP. This matters for load balancing because the connection is no longer stateless — it stays pinned to whichever backend server accepted the upgrade, for as long as the socket is open.

Requirements

Step 1: Install Nginx

sudo apt update
sudo apt install nginx -y
sudo systemctl enable nginx
sudo systemctl start nginx

Step 2: Understand the Two Headers That Matter Most

Two directives make WebSocket proxying work in Nginx, and if you forget them, your WebSocket connections will fail or silently degrade to polling:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

proxy_http_version 1.1 is required because WebSocket upgrades rely on HTTP/1.1 semantics (HTTP/1.0 doesn’t support the Upgrade header cleanly). The Upgrade and Connection headers need to be explicitly forwarded because Nginx does not pass them through by default — it strips hop-by-hop headers unless told otherwise.

There’s a subtlety with the Connection header: it needs to be "upgrade" only when the client actually requested an upgrade, and "close" or empty otherwise, so that normal HTTP requests to the same location block aren’t broken. Nginx has a built-in map variable for exactly this purpose, which I’ll use below.

Step 3: Set Up the Upstream Pool

Let’s say you have three backend Node.js servers running your WebSocket app on ports 4001, 4002, and 4003 (could be three separate machines or three processes on one box — same config either way).

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

upstream websocket_backend {
    ip_hash;
    server 10.0.0.11:4001;
    server 10.0.0.12:4002;
    server 10.0.0.13:4003;
}

Note the ip_hash directive. This is the simplest way to get sticky sessions in Nginx’s open-source version — it hashes the client’s IP address and consistently routes them to the same backend server as long as that server is healthy. For most real-time apps where a client connects, gets assigned a backend, and stays there for the life of the session, this is exactly the behavior you want.

Step 4: Configure the Server Block

server {
    listen 80;
    server_name ws.example.com;

    location /socket {
        proxy_pass http://websocket_backend;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # WebSockets need much longer timeouts than regular HTTP
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;

        # Disable buffering for real-time data
        proxy_buffering off;
    }
}

Let’s break down the parts that are specific to WebSocket traffic and not just a normal reverse proxy:

Step 5: Handle SSL / WSS

Real-world WebSocket apps run over wss:// (WebSocket Secure), which just means the underlying HTTPS connection is encrypted. Set this up the same way you would any HTTPS site:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d ws.example.com

After Certbot runs, your config gains a listen 443 ssl; block. Make sure the WebSocket-specific directives (proxy_http_version, Upgrade, Connection, timeouts) are present in that server block too — Certbot doesn’t duplicate custom location logic for you automatically in every case, so double check the generated file:

server {
    listen 443 ssl http2;
    server_name ws.example.com;

    ssl_certificate /etc/letsencrypt/live/ws.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ws.example.com/privkey.pem;

    location /socket {
        proxy_pass http://websocket_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 3600s;
        proxy_buffering off;
    }
}

server {
    listen 80;
    server_name ws.example.com;
    return 301 https://$host$request_uri;
}

Testing Your Setup

Test with nginx -t first, then reload:

sudo nginx -t
sudo systemctl reload nginx

For an actual connection test, wscat is the easiest tool:

npm install -g wscat
wscat -c wss://ws.example.com/socket

If you get connected and can send/receive messages, the proxy chain is working end to end. I also like to test failover explicitly: stop one backend server mid-session and confirm existing connections to other backends are unaffected, and that new connections route around the dead one.

You can also check which backend a connection landed on by having your app log its own port/hostname on connect, then correlating that with Nginx’s access log (add $upstream_addr to your log format):

log_format ws_log '$remote_addr - [$time_local] "$request" $status upstream=$upstream_addr';
access_log /var/log/nginx/ws_access.log ws_log;

Troubleshooting Common Issues

Connections upgrade then immediately close — Almost always a missing or incorrect Connection header. Double-check the map block is defined at the http level (not inside server), and that it’s referenced correctly in the location block.

Works over HTTP but not HTTPS — Usually means the WebSocket-specific directives weren’t copied into the 443 server block, or a certificate/mixed-content issue on the client side (make sure your JS client uses wss:// when the page is loaded over HTTPS).

Random disconnects every 60 seconds — Classic symptom of the default proxy_read_timeout. Increase it, and/or implement ping/pong heartbeats in your app.

One backend gets all the traffic, others sit idle — If you’re behind a corporate NAT or CDN, many clients may appear to come from the same IP, which breaks ip_hash‘s distribution. Consider least_conn combined with a cookie-based session persistence handled at the application layer instead, or use hash $cookie_session consistent; if your app sets a session cookie before the WebSocket handshake.

502 during deploys — If you restart a backend server, existing WebSocket connections to it drop and Nginx marks it down temporarily. This is often unavoidable without a proper drain/graceful-shutdown mechanism in your app, but you can reduce impact with max_fails and fail_timeout tuning:

server 10.0.0.11:4001 max_fails=2 fail_timeout=10s;

Security Considerations

limit_conn_zone $binary_remote_addr zone=ws_conn:10m;

location /socket {
    limit_conn ws_conn 20;
    ...
}

Performance Tips

events {
    worker_connections 8192;
}

Real-World Use Cases

Best Practices Recap

Once you’ve got this dialed in, Nginx becomes a genuinely invisible part of your real-time stack — connections upgrade cleanly, traffic spreads across your backend fleet, and you can scale horizontally just by adding another server to the upstream block.

Exit mobile version