How to Fix Nginx WebSockets "Connection: Upgrade" Header Dropped Behind Load Balancer
Quick answer
WebSocket connections work fine when testing directly against Nginx, but fail once a load balancer (ALB, ELB, or another reverse proxy) sits in front of it....
WebSocket connections work fine when testing directly against Nginx, but fail once a load balancer (ALB, ELB, or another reverse proxy) sits in front of it. This is because a WebSocket handshake depends on a specific pair of headers being preserved end-to-end through every hop, and it's easy for one layer in a multi-hop chain to silently drop or fail to forward them correctly.
The Problem
A WebSocket connection attempt fails to upgrade, falling back to a plain HTTP response or failing outright:
WebSocket connection to 'wss://yourapp.com/socket' failed:
Error during WebSocket handshake: Unexpected response code: 400
Checking the actual response headers reveals the upgrade never happened β the server responded with a normal HTTP response instead of the 101 Switching Protocols a successful WebSocket handshake requires:
$ curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://yourapp.com/socket
HTTP/1.1 400 Bad Request
Why It Happens
A WebSocket handshake is a regular HTTP request that asks the server to "upgrade" the connection to the WebSocket protocol, using the Connection: Upgrade and Upgrade: websocket headers together. Every hop in the chain between client and application β a load balancer, Nginx, any additional proxy β needs to correctly pass these headers through unmodified for the upgrade to succeed. This breaks when:
- Nginx's default
proxy_set_headerconfiguration doesn't explicitly forward theUpgradeandConnectionheaders β Nginx doesn't do this automatically for proxied requests, it needs to be configured explicitly for each location handling WebSocket traffic. - The load balancer in front of Nginx isn't configured to support WebSocket/HTTP upgrade connections β some load balancer configurations, particularly with certain listener or target group settings, don't correctly pass through upgrade headers, treating the connection as ordinary HTTP.
- A load balancer's idle timeout is too short for a long-lived WebSocket connection, closing the connection after this timeout even if the initial handshake succeeded, which manifests as connections that work briefly and then drop rather than failing to establish at all.
- Multiple layers of proxying each need their own explicit configuration β a fix at the Nginx layer doesn't help if the load balancer in front of it is the one actually dropping the headers before they ever reach Nginx.
The Fix
Configure Nginx to explicitly forward the required headers for any location handling WebSocket traffic β this is the most commonly missed piece, since it doesn't happen automatically with a plain proxy_pass:
location /socket {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s; # keep long-lived connections open
}
proxy_http_version 1.1 is required alongside these headers, since HTTP/1.0 (Nginx's older default for proxied connections) doesn't support the upgrade mechanism at all.
For AWS ALB specifically, WebSocket support is actually automatic and doesn't need explicit configuration on newer ALBs β but confirm your listener rules and target group health checks aren't interfering, and verify the ALB's idle timeout is long enough for your WebSocket connections' expected lifetime:
aws elbv2 modify-load-balancer-attributes \
--load-balancer-arn arn:aws:elasticloadbalancing:... \
--attributes Key=idle_timeout.timeout_seconds,Value=3600
For classic ELB (the older generation, less common now but still in use in some setups), explicit configuration for WebSocket pass-through may be required depending on the specific listener protocol configuration β check whether it's set to plain HTTP/HTTPS listeners (which can interfere with upgrade headers) versus TCP passthrough listeners (which don't inspect or modify the HTTP layer at all and are more reliable for WebSocket traffic).
If you have multiple proxy layers (a CDN, then a load balancer, then Nginx), verify header forwarding is correctly configured at every single layer, not just the one closest to your application β test progressively closer to the origin to isolate exactly which layer is the actual point of failure:
# Test directly against Nginx, bypassing the load balancer entirely
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://nginx-internal-ip/socket
If this direct test succeeds but the same request through the full public chain fails, the problem is confirmed to be somewhere between the load balancer and Nginx rather than in Nginx's own configuration.
Still Not Working?
If headers are correctly configured everywhere but connections still drop after a period of time rather than failing to establish initially, the issue is likely an idle timeout mismatch rather than a header forwarding problem β check every layer's timeout setting and ensure they're all comfortably longer than your application's expected WebSocket connection lifetime, or implement a periodic ping/pong keepalive within your WebSocket application logic itself so the connection never appears idle to any intermediate layer:
// Simple client-side keepalive ping every 30 seconds
setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({type: 'ping'}));
}
}, 30000);
An application-level keepalive is often the most robust fix regardless of how many proxy layers sit in the path, since it doesn't depend on correctly configuring timeout values across every single hop in a chain that might change over time as infrastructure evolves.