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
wslibrary, 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.comshould 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_conninstead ofip_hashif 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_connectionsinnginx.conf(in theeventsblock) 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 -nfor 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_connload 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.1and forwardUpgrade/Connectionheaders using themaptrick. - Raise
proxy_read_timeoutwell 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_addrso 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
wscatsession — 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.
