Nginx WebSocket Proxying
WebSockets start as HTTP, then “upgrade” to a long-lived bidirectional connection. By default nginx proxies HTTP and drops the upgrade — you have to opt into the WebSocket-aware behavior.
The minimum config
location /ws/ {
proxy_pass http://websocket_backend;
proxy_http_version 1.1; # required for upgrade
proxy_set_header Upgrade $http_upgrade; # forward the Upgrade header
proxy_set_header Connection "upgrade"; # MUST be "upgrade"
proxy_read_timeout 3600s; # long enough for your idle timeout
proxy_send_timeout 3600s;
}
Without proxy_http_version 1.1, the upgrade silently doesn’t happen. Without Connection "upgrade", the upstream sees Connection: close and refuses the WebSocket upgrade.
The “Upgrade” handshake
A WebSocket starts as a normal HTTP/1.1 request:
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
If the server accepts:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
After 101, the same TCP connection is no longer HTTP — it’s binary WebSocket frames in both directions until either side closes.
Why Connection "upgrade" (lowercase string)
The conventional pattern uses a map to flip Connection based on what the client sent:
http {
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
}
This way the same location can handle both regular HTTP requests (Connection: close) and WebSocket upgrades (Connection: upgrade).
Timeouts
WebSockets are long-lived. Default proxy_read_timeout is 60 seconds — meaning if no message flows in 60 seconds, nginx drops the connection.
proxy_read_timeout 3600s; # 1 hour idle tolerance
proxy_send_timeout 3600s;
Or implement application-level pings (ping/pong frames every 30s) so traffic flows constantly and nginx never times out. Most WebSocket libraries (websockets, channels, socket.io) ping by default.
Buffering off
proxy_buffering off;
WebSockets are streaming — you don’t want nginx to buffer messages waiting for “the response to finish.” For HTTP request/response that buffering protects upstreams from slow clients; for WebSockets it just adds latency and breaks real-time use cases.
Server-Sent Events (SSE) — same considerations
SSE is HTTP, not a true upgrade, but has the same issues: long-lived response, no buffering wanted.
location /events {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
chunked_transfer_encoding on;
}
Without proxy_buffering off, nginx buffers the entire SSE stream in memory waiting for it to “complete” — clients see no events. See ../sse/01_server_sent_events.md.
gRPC
gRPC is HTTP/2, not WebSocket. Nginx has a separate grpc_pass:
location / {
grpc_pass grpc://grpc_backend;
}
upstream grpc_backend {
server 10.0.0.1:50051;
}
server {
listen 443 ssl http2; # HTTP/2 required for gRPC
}
grpc_pass handles the HTTP/2 multiplexing, gRPC framing, trailers. Don’t try to proxy_pass gRPC traffic — it’ll mangle the HTTP/2 framing.
WebSocket and load balancing
A WebSocket lives on one TCP connection to one backend for its entire lifetime. Implications:
- Sticky sessions matter when state lives in worker memory (e.g. socket.io rooms across workers without an external broker).
- Backend restart kills connections. Clients need reconnect logic.
- Long-lived connections concentrate:
least_conndistributes initial connections, but they stay forever — load drifts as connections accumulate or drop.
For chat / pub-sub scale, the canonical pattern is:
- All WebSockets connected to N nginx-fronted workers.
- A shared message broker (Redis pub/sub, NATS, Kafka) for cross-worker fanout.
- Backend can be killed/replaced anytime; clients reconnect.
Limits to tune for high WebSocket counts
events {
worker_connections 65535; # each WebSocket is one connection
}
# OS-level
# Edit /etc/security/limits.conf and /etc/sysctl.conf
# fs.file-max = 1000000
# net.ipv4.ip_local_port_range = 1024 65535
Each WebSocket consumes:
- 1 file descriptor on nginx (for client side).
- 1 file descriptor on nginx (for upstream side).
- Memory: ~10 KB per connection in nginx, more in the application.
10k concurrent WebSockets per worker = 20k FDs = worker_connections 32768 minimum.
Common pitfalls
- Forgetting
proxy_http_version 1.1— upgrade silently doesn’t happen; clients fall back to long-polling or fail. - Default
proxy_read_timeout 60s— connections drop after 60s of idle. Either bump it or implement app-level pings. proxy_buffering on— SSE/streaming clients see no data until the buffer fills.- Using
ip_hashfor “stickiness” — many clients NAT’d through the same IP all hit the same backend. Use cookie-based stickiness orhash $cookie_session consistent. - Open-source nginx active health checks for WebSockets — only NGINX Plus supports this for WebSockets.
Common interview confusions
- “WebSockets work with
proxy_passlike normal HTTP.” — they do, after you add the upgrade headers. Without them the upgrade fails silently and connections drop. - “Increasing timeout fixes flaky WebSockets.” — only if the issue is idle timeout. Reconnections, network changes, mobile suspends all kill connections too.
- “WebSockets save bandwidth vs polling.” — yes for active use; the cost is long-lived connections occupying server resources whether the user is engaged or not.
Interview angle
- “What do you need to add to a
proxy_passblock to support WebSockets?” —proxy_http_version 1.1,proxy_set_header Upgrade $http_upgrade,proxy_set_header Connection "upgrade". Without all three, the WebSocket upgrade fails. - “Why does my WebSocket disconnect after 60 seconds?” — default
proxy_read_timeout. Bump to your max idle tolerance, or have the app send periodic pings to keep traffic flowing. - “How do you handle WebSockets across multiple backends?” — accept that one WebSocket = one backend. Use a shared broker (Redis pub/sub, NATS) for cross-backend message fanout instead of trying to make sticky sessions work.
- “Why is buffering off important for SSE?” — nginx buffers the response by default; for SSE the response never “completes” so the client sees nothing.
proxy_buffering offlets each event flow through immediately. - “What’s the difference between proxying WebSockets and gRPC in nginx?” — WebSockets are HTTP/1.1 with Upgrade; use
proxy_passplus upgrade headers. gRPC is HTTP/2; usegrpc_pass. Different protocols, different directives. - “How does nginx handle thousands of concurrent WebSockets?” — each is one file descriptor and ~10 KB of state in nginx’s event loop. Bump
worker_connectionsand OS file descriptor limits; nginx itself scales fine to hundreds of thousands per worker.