Backend / Protocols / nginx / 10_websocket_proxying.md

Nginx WebSocket Proxying

Updated 6 interview angles 5 min read source
On this page12
  1. The minimum config
  2. The “Upgrade” handshake
  3. Why Connection "upgrade" (lowercase string)
  4. Timeouts
  5. Buffering off
  6. Server-Sent Events (SSE) — same considerations
  7. gRPC
  8. WebSocket and load balancing
  9. Limits to tune for high WebSocket counts
  10. Common pitfalls
  11. Common interview confusions
  12. Interview angle

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

nginx
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:

text
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
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:

nginx
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.

nginx
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

nginx
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.

nginx
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 Server-Sent Events (SSE).

gRPC

gRPC is HTTP/2, not WebSocket. Nginx has a separate grpc_pass:

nginx
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_conn distributes 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

nginx
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_hash for “stickiness” — many clients NAT’d through the same IP all hit the same backend. Use cookie-based stickiness or hash $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_pass like 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 6

  • “What do you need to add to a proxy_pass block 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 off lets 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_pass plus upgrade headers. gRPC is HTTP/2; use grpc_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_connections and OS file descriptor limits; nginx itself scales fine to hundreds of thousands per worker.