WebSocket proxying looks deceptively simple until you hit the first timeout, load balancer mismatch, or upgrade failure. I have seen people copy a normal reverse proxy block, change the upstream URL, and assume it is done. Then they wonder why the browser reports a failed handshake, why idle connections die at sixty seconds, or why one backend ends up holding almost every socket.
This guide covers the Nginx configuration I actually use for WebSocket traffic, what the Upgrade flow is doing, and how I troubleshoot the usual failure patterns. If you need the reverse proxy basics first, read my Nginx reverse proxy guide. If your WebSocket app is behind a pool of backends, keep my upstream load balancing article nearby.
How the WebSocket upgrade works
A WebSocket connection starts as an HTTP request. The client asks the server to upgrade the connection using the Upgrade: websocket header. If the server accepts, it responds with 101 Switching Protocols and the connection changes from normal HTTP request-response handling into a persistent bidirectional socket.
That is the core reason WebSockets break on misconfigured proxies. Nginx has to pass the upgrade request correctly, preserve HTTP/1.1 behavior, and then keep the connection open long enough for the application to use it.
A typical request from the client includes headers like these:
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
If Nginx strips or rewrites the important parts incorrectly, the backend never sees a valid upgrade request.
The minimum working Nginx config
This is the baseline I start from:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream websocket_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name ws.example.com;
location /socket/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $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 $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://websocket_backend;
}
}
The critical directives are:
proxy_http_version 1.1proxy_set_header Upgrade $http_upgradeproxy_set_header Connection $connection_upgrade
Without those, WebSocket proxying usually fails outright.
Why HTTP/1.0 breaks WebSockets
Nginx uses HTTP/1.0 to upstreams by default for normal proxying. That is fine for many applications and absolutely wrong for WebSockets.
You must force HTTP/1.1:
proxy_http_version 1.1;
I still see inherited configs missing this line. The result is usually a failed handshake or a backend that never recognizes the upgrade request.
If somebody says, “the app works directly on port 3000 but fails through Nginx,” this is one of the first lines I check.
Why I prefer the map pattern for Connection
A lot of examples hardcode this:
proxy_set_header Connection "Upgrade";
That can work, but I prefer the map approach because it only sends upgrade when the client actually requested an upgrade.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Then in the location:
proxy_set_header Connection $connection_upgrade;
It is cleaner and behaves better when the same location may see both upgrade and non-upgrade traffic.
TLS and WSS: ws:// versus wss://
If the site is exposed over HTTPS, the browser will normally expect secure WebSocket connections as well, using wss:// instead of ws://.
A typical TLS-enabled server block looks like this:
server {
listen 443 ssl http2;
server_name ws.example.com;
ssl_certificate /etc/letsencrypt/live/ws.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ws.example.com/privkey.pem;
location /socket/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://websocket_backend;
}
}
The backend can still speak plain ws:// on a private interface while Nginx terminates TLS. That is normal. Just make sure the browser-facing URL is wss:// when the site is secure.
If you are also tightening headers and TLS policy, review your HTTPS configuration and certificate deployment at the same time.
Timeout handling: why defaults are often too short
WebSockets are long-lived connections. Normal HTTP timeouts that are acceptable for short requests can be far too aggressive here.
The first settings I review are:
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_read_timeout is especially important. If the backend sends nothing for longer than that interval, Nginx closes the connection. Some applications send heartbeats or ping frames often enough that this never matters. Others stay quiet for long periods and get cut off unexpectedly.
I learned not to “fix” this by setting absurdly huge timeouts everywhere without understanding the application. Better is to set realistic proxy timeouts and make sure the app or client has a heartbeat strategy.
Application keepalives and reconnection behavior
A WebSocket proxy timeout should not be the only thing keeping a connection healthy. The application or client should handle reconnects gracefully.
When I test a WebSocket stack, I check both sides:
- does the server send periodic ping or heartbeat traffic?
- does the client reconnect cleanly after disconnect?
- does reconnect create duplicate subscriptions or leaked sessions?
Nginx can keep a quiet connection open longer, but it cannot fix a client that never reconnects or a backend that leaks resources on reconnect storms.
Location block ordering matters
One common mistake is placing a generic proxy location above the WebSocket-specific path or writing a regex location that catches the request first.
A safe approach is to give the WebSocket path an explicit prefix location:
location /socket/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://websocket_backend;
}
location / {
proxy_pass http://web_backend;
}
If /socket/ is supposed to upgrade and the request lands in the generic / block instead, you get confusing failures. I have seen that happen after innocent-looking refactors where somebody rearranged locations without thinking about protocol differences.
Validate the running config, not just the file you edited:
sudo nginx -T | sed -n '/server_name ws.example.com/,/}/p'
Load balancing WebSocket backends
Load balancing WebSockets is not the same as load balancing short stateless HTTP requests. Once a socket connects, it tends to stay pinned to one backend for a long time.
If the app keeps session or subscription state in process memory, sticky behavior is often required. A simple option is ip_hash:
upstream websocket_backend {
ip_hash;
server 10.0.0.11:3000;
server 10.0.0.12:3000;
}
This keeps the same client IP landing on the same backend where possible. It is not perfect. Clients behind NAT can skew distribution, and reconnects from changing mobile IPs may not stay sticky.
Where possible, I prefer applications that externalize shared state so the proxy does not need to guarantee affinity. But in the real world, ip_hash is often the fastest workable option.
Logging and validating the handshake
I like to log enough detail to confirm that the request reached the intended backend and whether the response was 101.
A simple validation with curl is limited because it does not complete a full WebSocket conversation easily, but it can still test the upgrade response path:
curl --http1.1 -i -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==' -H 'Sec-WebSocket-Version: 13' http://ws.example.com/socket/
You want to see 101 Switching Protocols. If you get 400, 426, 502, or a plain 200, the handshake path is wrong.
For more realistic testing, I use wscat or websocat.
Testing with wscat
If Node.js tooling is available:
npx wscat -c ws://ws.example.com/socket/
For TLS:
npx wscat -c wss://ws.example.com/socket/
That lets me verify connection establishment and manual message flow quickly.
Testing with websocat
I also like websocat because it is lightweight and script-friendly.
websocat ws://ws.example.com/socket/
websocat wss://ws.example.com/socket/
If these tools fail through Nginx but succeed directly against the backend, the proxy config is still the prime suspect.
Buffering, request headers, and backend expectations
Most WebSocket applications do not want proxy buffering behavior designed for normal HTTP responses. For many setups I leave buffering off on the WebSocket path so Nginx is not trying to optimize a bidirectional stream like a static file transfer.
location /socket/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_request_buffering off;
proxy_pass http://websocket_backend;
}
Not every application needs both directives changed, but if I see odd latency, delayed messages, or a framework complaining about request body handling on upgrade paths, I review them. I also make sure the backend gets the headers it uses for origin checks, scheme awareness, and client IP logging. Some frameworks behave differently when Host or X-Forwarded-Proto is missing.
Socket.IO and custom path gotchas
A lot of “WebSocket problems” are really path problems. Socket.IO, for example, may use a default path like /socket.io/ and may fall back to long polling if the WebSocket upgrade path is not available. That can trick you into thinking everything works while the app is actually using a slower transport.
If the application expects /socket.io/, proxy that exact path deliberately:
location /socket.io/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_pass http://websocket_backend;
}
Then test in the browser developer tools and confirm you see a real 101 upgrade, not a silent fallback to polling. I have had incidents where the app “worked” but backend CPU was much higher than expected because clients were polling every few seconds instead of holding proper sockets.
Timeouts outside Nginx
When idle WebSockets die on a predictable interval, Nginx is only one suspect. I check every hop in the path:
- browser-side heartbeat interval
- CDN or WAF idle timeout
- cloud load balancer idle timeout
- Nginx
proxy_read_timeout - backend framework timeout
That layered view matters. I once raised the Nginx timeout to an hour and the disconnects still happened every hundred seconds because the cloud load balancer in front of it had its own default idle limit. The browser console blamed the WebSocket, Nginx looked innocent, and the real problem was one layer earlier.
Browser-side debugging and handshake inspection
For browser clients, I always check the Network tab during the handshake. The useful clues are:
- request URL and path
UpgradeandConnectionrequest headers- response code, ideally
101 - whether the connection switches protocols or falls back to polling
On the server side, I compare that with Nginx logs and the application logs. If the browser never sends the upgrade headers, I stop blaming Nginx. If the browser sends them and Nginx returns 400 or 200, I inspect location matching and header forwarding. If Nginx returns 101 but the socket dies immediately, I look at the backend and any upstream proxy tier next.
Authentication, Origin checks, and handshake security
WebSocket connections deserve the same security review as normal application endpoints. The browser handshake may look small, but it can still carry cookies, bearer tokens, and session state. I make sure the application is clear about how it authenticates the socket and what it trusts from the client.
A few things I check early:
- whether authentication happens on the handshake or immediately after connect
- whether the backend validates the
Originheader for browser clients - whether cookies used for the socket path are scoped correctly
- whether direct backend access is blocked so clients cannot bypass Nginx
If the application expects a particular origin, make sure Nginx is not rewriting or stripping the header unexpectedly. I do not usually set Origin manually at the proxy, but I do verify the backend sees what the browser actually sent. This matters for browser-based apps that rely on origin validation to stop cross-site misuse.
I also avoid exposing raw backend ports publicly when Nginx is supposed to be the entry point. Otherwise you can spend time hardening the proxy path while clients quietly bypass it and connect to a weaker listener.
Debugging common WebSocket failures
400 Bad Request from Nginx
This often means the request did not match the expected upgrade pattern, a header is missing, or the request landed in the wrong location block.
No 101 Switching Protocols
If the response is not 101, the upgrade did not happen. Check proxy_http_version 1.1, Upgrade, and Connection headers first.
502 Bad Gateway
Usually the upstream is down, refusing connections, or responding incorrectly. My 502 troubleshooting guide is the right companion here.
Idle disconnects at exactly 60 seconds or another neat interval
That smells like a timeout somewhere in Nginx, a CDN, a cloud load balancer, or the application itself.
Backend sees plain HTTP but not upgrade traffic
Usually wrong location matching or header forwarding.
Watching logs during testing
While testing, I tail both access and error logs.
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log
For targeted error patterns:
sudo tail -f /var/log/nginx/error.log | grep --line-buffered -E 'upstream|timed out|upgrade|connect\(\) failed'
This is where my Nginx log analysis guide helps, especially if the problem only appears under concurrency.
WebSockets behind SSL offload, CDNs, and extra proxies
Nginx is not always the only proxy in the path. Cloud load balancers, CDN layers, WAFs, or ingress controllers may sit in front of it.
When troubleshooting, I check every hop for:
- WebSocket support being enabled at all
- idle timeout values
- header preservation
- maximum connection limits
- HTTP version behavior
It is very common to fix Nginx and still have disconnects because an upstream CDN closes idle WebSocket connections after one minute.
Security considerations
WebSocket connections are easy to forget because they look like a special case, but the same basics still apply.
I still validate:
- origin handling if the app cares about browser-origin trust
- authentication on the handshake path
- TLS for public traffic
- request size and header sanity
- backend exposure only through the proxy
I also make sure error pages do not leak internal backend details. A failed upgrade should not tell the world which private IP or app server crashed.
Deprecated and outdated advice to ignore
There is still old advice floating around that treats WebSocket proxying as exotic or recommends awkward hacks. Two things I avoid:
- relying on upstream HTTP/1.0 behavior and hoping headers compensate
- copying very old config examples without validating them against current Nginx syntax
Current Nginx handles WebSockets cleanly when the right headers and timeouts are set. The trouble is almost always in the missing details.
Final thoughts
WebSocket proxying in Nginx is not hard once you remember that the initial request is still HTTP and the upgraded connection is long-lived. Get the upgrade headers right, force HTTP/1.1 to the upstream, set realistic timeouts, and think carefully about load balancing if the application holds state in memory.
When I troubleshoot WebSockets, I look for one of three root causes first: wrong headers, wrong timeouts, or wrong assumptions about affinity. Most incidents end up in one of those buckets. A short, deliberate checklist beats random proxy edits every time.