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

  • A Linux server with Nginx 1.3.13 or newer (WebSocket proxying support was added then; anything from the last several years is fine)
  • Two or more backend WebSocket servers (I’ll use a Node.js example running Socket.IO or the ws library, but the config is identical for any language)
  • Basic familiarity with your backend app’s session/auth model, since that affects whether you need sticky sessions

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:

  • proxy_read_timeout / proxy_send_timeout — Nginx’s default timeout is 60 seconds. If a WebSocket connection is idle (no data flowing) for longer than that, Nginx will close it, even though the client and backend both think it’s still open. Setting this to an hour (or longer, depending on your use case) prevents unexpected disconnects. Many apps also implement application-level ping/pong frames every 20–30 seconds specifically to keep this timeout from ever being hit.
  • proxy_buffering off — For a real-time app, you don’t want Nginx buffering chunks of the response before forwarding them; that defeats the purpose of “real-time.”

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

  • Validate the Origin header in your backend application — Nginx can forward it, but only your app can decide if Origin: https://evil.com should be allowed to open a WebSocket to your service.
  • Rate limit connection attempts to prevent a flood of upgrade requests from exhausting backend resources:
limit_conn_zone $binary_remote_addr zone=ws_conn:10m;

location /socket {
    limit_conn ws_conn 20;
    ...
}
  • Authenticate before upgrading where possible — pass a short-lived token in the connection URL or an early message, validated by your backend, rather than trusting the initial handshake alone.
  • Always run over wss:// in production. Plaintext WebSocket traffic is trivially sniffable, especially painful for chat or anything carrying auth tokens.
  • Set server_tokens off; globally to avoid leaking your Nginx version in headers.

Performance Tips

  • Use least_conn instead of ip_hash if your app manages session affinity itself (e.g., via Redis-backed pub/sub so any backend can serve any client) — this gives you much better load distribution across backends since it’s not tied to client IP.
  • Scale horizontally with a shared state layer. True WebSocket scaling usually means backends publish/subscribe to a shared broker (Redis, NATS, RabbitMQ) so that a message from client A on server 1 can reach client B on server 2. Nginx handles the connection routing; the message fan-out is your application’s job.
  • Tune worker connections. Since WebSocket connections are long-lived, each open connection ties up an Nginx worker connection slot. Check worker_connections in nginx.conf (in the events block) and raise it if you expect thousands of concurrent connections:
events {
    worker_connections 8192;
}
  • Monitor open file descriptors. Each connection, both client-side and to the upstream, uses a file descriptor. Check and raise ulimit -n for the Nginx process if you’re running at scale.
  • Enable HTTP/2 for the HTTPS side, though note HTTP/2’s multiplexing doesn’t apply to the WebSocket connection itself once upgraded (WebSocket over HTTP/2 has separate semantics using CONNECT), so don’t expect a huge WS-specific speedup from this alone.

Real-World Use Cases

  • A live collaborative document editor where dozens of users edit the same doc simultaneously — Nginx routed connections with ip_hash, and the backend synced state through a shared Redis pub/sub channel so edits from any server reached every connected client.
  • A trading dashboard streaming price ticks to thousands of concurrent viewers — least_conn load balancing across a fleet of stateless WebSocket servers, each subscribing to the same upstream market data feed.
  • A multiplayer game lobby system where matchmaking happened over regular HTTP (load balanced normally) but gameplay itself used WebSockets pinned to a specific game server for the duration of a match.

Best Practices Recap

  • Always set proxy_http_version 1.1 and forward Upgrade/Connection headers using the map trick.
  • Raise proxy_read_timeout well above your app’s heartbeat interval.
  • Choose your load balancing method (ip_hash, least_conn, or app-level stickiness) based on whether your backends share state.
  • Terminate SSL at Nginx and enforce wss:// everywhere.
  • Log $upstream_addr so you can debug which backend handled which connection.
  • Plan for graceful backend restarts — WebSocket connections don’t survive a backend crash gracefully by default.
  • Load test with realistic concurrency, not just a single wscat session — connection-count limits surface only under real load.

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.

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Nginx with Flask and uWSGI

How to Set Up Nginx with Flask and uWSGI

Next Post
How to Set Up Nginx for a Ruby Application

How to Set Up Nginx for a Ruby Application

Related Posts