backend / protocols / nginx / 10_websocket_proxying.md

Nginx WebSocket Proxying

6 interview angles 5 min read source

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_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

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

  • “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.