Skip to content

Fix: Nginx WebSocket Proxy Not Working (101 Switching Protocols Failed)

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Nginx WebSocket proxying not working — 101 Switching Protocols fails, connections drop after 60 seconds, missing Upgrade headers, and SSL WebSocket configuration.

The Error

You configure nginx to proxy WebSocket connections but the browser console shows:

WebSocket connection to 'wss://example.com/socket' failed:
Error during WebSocket handshake: Unexpected response code: 502

Or:

WebSocket connection to 'ws://localhost/socket' failed:
Error during WebSocket handshake: net::ERR_CONNECTION_RESET

Or the WebSocket connects initially but drops after exactly 60 seconds:

WebSocket connection closed unexpectedly

Or the server returns 200 OK instead of 101 Switching Protocols.

Why This Happens

WebSocket connections start as HTTP requests and are upgraded to a persistent bidirectional connection using the 101 Switching Protocols response. The client sends an HTTP/1.1 request with Upgrade: websocket and Connection: Upgrade headers, plus a Sec-WebSocket-Key nonce. The server responds with 101 Switching Protocols and a Sec-WebSocket-Accept hash, after which the TCP connection becomes a full-duplex binary channel.

Nginx does not proxy WebSocket connections by default — it treats them as regular HTTP and drops the Upgrade and Connection headers because they are listed as hop-by-hop headers in RFC 7230 and proxies are supposed to remove hop-by-hop headers. The Upgrade-aware behavior must be explicitly re-enabled per location.

Common causes:

  • Missing Upgrade and Connection headers in the nginx proxy config — required for WebSocket handshake.
  • proxy_http_version not set to 1.1 — WebSocket requires HTTP/1.1; nginx defaults to HTTP/1.0 for upstream connections.
  • Proxy timeout too short — nginx’s default proxy_read_timeout is 60 seconds, dropping idle WebSocket connections.
  • SSL termination misconfiguredwss:// (WebSocket over TLS) requires specific nginx SSL proxy setup.
  • Upstream not WebSocket-capable — the backend server needs to accept WebSocket connections on the proxied path.
  • A second layer of proxy (Cloudflare, ALB, GCP LB, Kubernetes ingress) stripping headers or imposing its own timeout, even though your nginx config is correct.

Platform and Environment Differences

WebSocket through nginx fails in different ways depending on what is in front of, around, or behind nginx. The same nginx config can work in one environment and silently fail in another.

Nginx OSS vs Nginx Plus. OSS nginx supports WebSocket proxying with the Upgrade/Connection headers shown below. Nginx Plus adds sticky cookie and sticky route for session-affinity load balancing (OSS only offers ip_hash). If you need true sticky sessions behind NAT or with many users sharing one IP, you need Plus or an external session-aware load balancer.

Cloudflare in front of nginx. Cloudflare proxies WebSocket connections by default on Free and higher tiers, but with a tier-dependent timeout: Free has a hard 100-second idle timeout, Pro and above raise it to 100 seconds for the WebSocket upgrade phase and longer thereafter. Connections idle longer than that are killed regardless of your nginx config. Implement application-layer pings every 30 seconds to keep the Cloudflare edge from closing the connection. Also confirm WebSocket is enabled in Network → WebSockets in the Cloudflare dashboard (it is on by default, but some Enterprise configs disable it).

AWS ALB / NLB / Classic ELB. Application Load Balancer (ALB) supports WebSocket natively since 2018, with a default idle timeout of 60 seconds (raise to up to 4000 seconds in target group settings). Network Load Balancer (NLB) is pure L4 and passes WebSocket bytes transparently with no protocol awareness — timeout is 350 seconds and not adjustable. Classic ELB requires TCP listeners, not HTTP, for WebSocket (and is deprecated). Use ALB for WebSocket unless you need NLB’s higher PPS and lower latency.

GCP HTTPS Load Balancer. GCP’s global external HTTPS LB has a default backend timeout of 30 seconds for WebSocket. Raise the timeout in the backend service config, or it will tear down the connection mid-handshake. The GCP LB also requires the backend to respond with 101 Switching Protocols within that window — slow upstream handshakes cause 502.

Caddy and Traefik. Caddy proxies WebSocket out of the box with no extra config — reverse_proxy understands Upgrade. Traefik also handles WebSocket transparently when you use a router with no special middleware. If you migrate from Caddy/Traefik to nginx, the WebSocket “just worked” in your old setup because those proxies do the header dance automatically; nginx requires the explicit proxy_set_header lines below.

Kubernetes ingress. Behavior depends on the controller:

  • ingress-nginx (kubernetes/ingress-nginx): WebSocket works without annotations, but the default proxy-read-timeout is 60s. Add nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" and proxy-send-timeout: "3600" to the Ingress.
  • HAProxy ingress: Add haproxy-ingress.github.io/timeout-tunnel: "1h".
  • Contour / Envoy: WebSocket works by default in Contour 1.0+, but uses the per-route idleTimeout setting.
  • Traefik ingress: No annotations needed; works out of the box.

See also Kubernetes ingress not working for general ingress debugging.

Behind another nginx (double-proxy). If you have nginx → nginx → app, you must set the Upgrade/Connection headers on every nginx hop. Stripping happens at each layer because each treats the next as an upstream.

HTTP/2 and HTTP/3. WebSocket-over-HTTP/2 (RFC 8441) requires :protocol pseudo-header and is not yet supported by mainstream browsers for WebSocket API calls — browsers downgrade to HTTP/1.1 for WebSocket. Nginx listens with http2 but the upgrade falls back automatically. If you see WebSocket failures only on HTTP/2 listeners, the client may be misbehaving — confirm with chrome://net-export/.

Docker / Compose. When nginx runs in a container, the upstream proxy_pass http://localhost:3000 resolves to localhost inside the nginx container, not your app. Use the service name (proxy_pass http://app:3000) and ensure both services are on the same Compose network. See Docker Compose networking not working for common pitfalls.

Fix 1: Add the Required Upgrade Headers

The minimal nginx config to proxy WebSocket connections:

server {
    listen 80;
    server_name example.com;

    location /socket/ {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;

        # Required for WebSocket handshake
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        # Standard proxy headers
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The three critical settings:

  1. proxy_http_version 1.1 — WebSocket requires HTTP/1.1. Nginx defaults to HTTP/1.0 for proxied requests.
  2. proxy_set_header Upgrade $http_upgrade — passes the Upgrade: websocket header from the client to the upstream.
  3. proxy_set_header Connection "upgrade" — tells the upstream server to upgrade the connection.

Without these, nginx strips the Upgrade header and responds with a regular HTTP response instead of 101 Switching Protocols.

Pro Tip: Use a map block to handle both WebSocket and regular HTTP connections on the same location — this allows the same nginx config to work for both upgrade and non-upgrade requests:

map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}

server {
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

When $http_upgrade is empty (regular HTTP), Connection: close is set. When it is websocket, Connection: upgrade is set.

Fix 2: Increase Proxy Timeouts

Nginx’s default proxy_read_timeout is 60 seconds. An idle WebSocket connection with no data for 60 seconds gets dropped. Increase all relevant timeouts:

location /socket/ {
    proxy_pass http://localhost:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    # Increase timeouts for long-lived WebSocket connections
    proxy_read_timeout 3600s;    # 1 hour
    proxy_send_timeout 3600s;    # 1 hour
    proxy_connect_timeout 10s;   # Connection establishment timeout (keep short)

    # Disable buffering for real-time data
    proxy_buffering off;
    proxy_cache off;
}

proxy_read_timeout is the timeout between two successive read operations from the upstream — for WebSocket, this means “how long to wait for the upstream to send data.” Set it higher than the longest expected idle period.

Alternatively, configure WebSocket ping/pong in your application to keep the connection alive:

// Node.js WebSocket server (ws library)
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 3000 });

wss.on("connection", function(ws) {
  // Send ping every 30 seconds
  const pingInterval = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.ping();
    }
  }, 30000);

  ws.on("close", () => clearInterval(pingInterval));
});

Ping/pong keeps the connection alive and is more reliable than relying on nginx timeout settings.

Fix 3: Configure wss:// (WebSocket over SSL)

For HTTPS sites, WebSocket connections must use wss:// (WebSocket Secure). Configure nginx to handle SSL termination and proxy to an unencrypted backend:

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location /socket/ {
        proxy_pass http://localhost:3000;  # Backend uses plain ws://
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header 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;
        proxy_set_header X-Forwarded-Proto https;  # Tell backend the original protocol

        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

The client connects via wss://example.com/socket/. Nginx terminates SSL and proxies to http://localhost:3000 (plain WebSocket). The backend does not need to handle SSL.

Client-side connection:

// Automatically use wss:// on HTTPS pages
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${window.location.host}/socket/`);

Fix 4: Proxy WebSocket to a Specific Path or Port

If the WebSocket server runs on a different port or path than the HTTP server:

# Backend: HTTP on port 3000, WebSocket on port 3001
server {
    listen 80;
    server_name example.com;

    # Regular HTTP requests
    location / {
        proxy_pass http://localhost:3000;
    }

    # WebSocket connections to a different backend port
    location /ws/ {
        proxy_pass http://localhost:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }
}

If the WebSocket path needs to be rewritten:

location /socket/ {
    # Strip /socket/ prefix before passing to backend
    proxy_pass http://localhost:3000/;  # Trailing slash rewrites the path
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

A trailing slash on proxy_pass strips the location prefix. /socket/chat becomes /chat at the backend.

Fix 5: Fix nginx Load Balancing with WebSockets

When proxying WebSockets to multiple upstream servers, connections must be sticky (each WebSocket connection stays with the same upstream server, since WebSocket is stateful):

upstream websocket_backends {
    ip_hash;  # Sticky sessions based on client IP
    server backend1:3000;
    server backend2:3000;
    server backend3:3000;
}

server {
    listen 80;

    location /socket/ {
        proxy_pass http://websocket_backends;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }
}

ip_hash ensures all requests from the same client IP go to the same upstream. For more precise stickiness (especially behind a NAT where many users share one IP), use sticky cookie from nginx Plus or a session-aware load balancer.

Alternative: use Redis pub/sub to share WebSocket messages across multiple server instances (Socket.IO supports this with socket.io-redis).

Fix 6: Debug WebSocket Connection Issues

Test the upstream WebSocket server directly:

# Test WebSocket connection directly (bypassing nginx)
wscat -c ws://localhost:3000/socket
# If this fails, the issue is in your backend, not nginx

Install wscat:

npm install -g wscat

Test through nginx:

wscat -c ws://example.com/socket
# If direct works but through nginx fails, the issue is nginx config

Check nginx error logs:

sudo tail -f /var/log/nginx/error.log
# Look for: "no live upstreams", "connect() failed", "upstream timed out"

Test with curl to see the response headers:

curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  http://example.com/socket/

Expected response: HTTP/1.1 101 Switching Protocols. If you get 200 OK or 502 Bad Gateway, the WebSocket upgrade is not happening.

Enable nginx debug logging for a specific connection:

error_log /var/log/nginx/error.log debug;

Fix 7: Fix WebSocket with Docker and Compose

When nginx and the WebSocket backend run in Docker containers, use container names as upstream addresses:

# nginx.conf inside Docker
upstream websocket_backend {
    server app:3000;  # 'app' is the Docker service name
}

server {
    listen 80;

    location /socket/ {
        proxy_pass http://websocket_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }
}
# docker-compose.yml
services:
  nginx:
    image: nginx:alpine
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    ports:
      - "80:80"
    depends_on:
      - app

  app:
    image: myapp:latest
    expose:
      - "3000"

Both services must be on the same Docker network (Compose creates a default network automatically).

Still Not Working?

Check for double Connection header. If another middleware or proxy is already setting Connection: close, it overrides your Connection: upgrade. Verify with curl -v that the upstream receives Connection: upgrade.

Check browser WebSocket error codes. The browser’s Network tab shows the WebSocket upgrade request. Status 101 means success. 400 Bad Request means the upstream rejected the upgrade. 502 Bad Gateway means nginx could not reach the upstream.

Check for HTTP/2 conflicts. HTTP/2 uses a different multiplexing mechanism and does not support WebSocket upgrades in the same way. If you have http2 in your nginx listen directive, WebSocket connections from HTTP/2 clients need special handling:

listen 443 ssl;  # Remove 'http2' if WebSocket is breaking
# Or use HTTP/2 and WebSocket on separate locations

Check Cloudflare Polish/Rocket Loader/Auto Minify. Some Cloudflare optimization features inject themselves into responses and can interfere with the upgrade. Disable Auto Minify and Rocket Loader for the WebSocket route via a Page Rule or Configuration Rule.

Check for proxy_buffering on a parent block. Even if you set proxy_buffering off in your location, an http or server block default of proxy_buffering on can cascade. Run nginx -T | grep -i buffer to dump the effective config and confirm.

Check the file descriptor limit. A WebSocket server holding 10,000 concurrent connections needs 10,000 FDs plus the upstream nginx side needs another 10,000. If either side hits the per-process FD limit, new connections fail with EMFILE or 502. See EMFILE too many open files for raising the limit per service.

Check worker_connections in nginx. This is the per-worker concurrent connection cap (default 512 or 1024). Each WebSocket counts as two connections (client + upstream). Raise it to match expected load:

events {
    worker_connections 65536;
}

For nginx 502 errors in general, see Fix: Nginx 502 Bad Gateway.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles