WebSocket Connections Dropping Silently Behind Nginx Reverse Proxies
Your WebSocket connects fine on localhost, but in production it silently dies after about 60 seconds of low activity. No error event fires on the client. The connection just goes dark. Users only notice when they stop receiving updates.
This is one of the most disorienting WebSocket bugs because nothing looks wrong β until you watch the browser's network tab long enough. The culprit is almost always Nginx sitting between your client and your WebSocket server, quietly killing idle connections because nobody told it not to.
What You'll Learn
- Why Nginx drops WebSocket connections by default and when it happens
- The exact Nginx directives you need to keep connections alive
- How to write client-side reconnect logic that handles drops gracefully
- How to implement a heartbeat to detect dead connections before your users do
- Common configuration mistakes that cause the problem to persist after you think you've fixed it
Prerequisites
- A working Nginx reverse proxy setup (any version from 1.3.13 onward supports WebSocket proxying)
- A WebSocket server running behind it (Node.js, Python, Go β the Nginx config is the same)
- Basic familiarity with the WebSocket API in the browser
The Problem: Silent Drops in Production
HTTP/1.1 introduced the concept of persistent connections, but Nginx was designed with traditional request/response cycles in mind. A WebSocket connection looks like a normal HTTP request until the 101 Switching Protocols upgrade completes β and after that, from Nginx's perspective, it's a long-running proxy connection with no data moving through it.
Nginx has a directive called proxy_read_timeout that controls how long it waits for data from the upstream before closing the connection. Its default is 60 seconds. If your WebSocket is idle for more than a minute β no messages in either direction β Nginx closes the TCP connection without sending a close frame to either side. Both your server and your client may not realize they're disconnected until they try to send something.
This is the core of the problem. The WebSocket spec has its own ping/pong mechanism for keep-alives, but those frames travel through Nginx as data. If Nginx closes the connection before any ping goes out, the keep-alive never gets a chance to work.
What Actually Happens Between Nginx and Your WebSocket Server
When a client initiates a WebSocket connection, the browser sends an HTTP GET with an Upgrade: websocket header. Nginx must forward that upgrade request to the upstream server, which responds with 101 Switching Protocols. At that point, the connection stops being HTTP and becomes a raw bidirectional TCP stream.
Nginx supports this but doesn't do it automatically. You have to explicitly tell it to pass the Upgrade and Connection headers. Without them, Nginx strips those headers before forwarding the request β the upstream never gets the upgrade signal, and you get a plain HTTP response back instead of a 101.
Even when headers are configured correctly, the timeout issue remains a separate problem. Think of it as two bugs you have to fix independently: the headers that make WebSocket work at all, and the timeout that makes it stay alive.
This same category of hard-to-diagnose issues shows up in other production contexts. If you've dealt with fetch requests hanging indefinitely when the server never responds, the timeout-versus-silence pattern will feel familiar.
Why Nginx Drops WebSocket Connections
Beyond proxy_read_timeout, there are a few other Nginx behaviors that contribute to silent drops:
- proxy_send_timeout β controls how long Nginx waits to send data to the upstream. Also defaults to 60 seconds.
- keepalive_timeout β this controls the HTTP keepalive timeout for client-to-Nginx connections. It affects WebSocket sessions differently than HTTP requests, but a low value can still cause premature teardown in some configurations.
- HTTP version β by default, Nginx proxies to upstreams using HTTP/1.0, which doesn't support persistent connections. You need to explicitly set HTTP/1.1 for WebSocket proxying.
- Connection header stripping β Nginx strips hop-by-hop headers including
Connectionbefore passing requests upstream. If you don't explicitly set theConnectionheader toupgrade, the upgrade handshake fails silently.
Configuring Nginx to Keep WebSocket Connections Alive
The Upgrade Headers
Start with the headers. Add these to your location block that handles WebSocket traffic. If your WebSocket endpoint is at /ws/, your block should look like this:
location /ws/ {
proxy_pass http://your_upstream_server;
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;
}
The critical lines are proxy_http_version 1.1 and the two proxy_set_header directives for Upgrade and Connection. Without proxy_http_version 1.1, Nginx falls back to HTTP/1.0 and the persistent connection model breaks entirely.
Timeout Settings
Now address the timeouts. Add these to the same location block:
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
Setting these to 3600 seconds (one hour) is a common production choice, but the right value depends on your application. If you have heartbeat logic on the client or server that sends a ping every 30 seconds, you could set the timeout to something like 120 seconds β high enough to survive one missed ping, low enough to reclaim resources from truly dead connections. More on that in the heartbeat section below.
Upstream Keepalive
If you're using an upstream block, add a keepalive directive to it. This tells Nginx to maintain a pool of idle connections to the upstream, reducing handshake overhead for reconnects:
upstream websocket_backend {
server 127.0.0.1:8080;
keepalive 64;
}
server {
listen 443 ssl;
location /ws/ {
proxy_pass http://websocket_backend;
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;
proxy_send_timeout 3600s;
}
}
The keepalive 64 tells Nginx to keep up to 64 idle upstream connections open in the worker process's cache. This doesn't directly affect the WebSocket timeout, but it matters when your clients reconnect frequently β they won't pay the TCP and TLS handshake cost every time.
Testing Your Nginx Config
Before reloading, test the syntax:
nginx -t
Then reload without dropping existing connections:
nginx -s reload
To confirm the WebSocket upgrade is working, open your browser's DevTools, go to the Network tab, filter by WS, and connect. You should see a 101 status code and the connection staying open. If you see a 400 or the connection closes immediately, the headers are still misconfigured.
To simulate the timeout issue specifically, establish a WebSocket connection and then keep it idle. Watch the network tab. With the old config, it would die around 60 seconds. With your new timeouts, it should stay open well past that point.
Adding Client-Side Reconnect Logic
Even with a well-configured Nginx, connections will drop occasionally β network hiccups, server deploys, load balancer restarts. You need a client that handles this gracefully, not one that silently gives up.
Here's a minimal reconnecting WebSocket wrapper in plain JavaScript:
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.maxRetries = options.maxRetries ?? 10;
this.retryDelay = options.retryDelay ?? 2000;
this.retries = 0;
this.socket = null;
this.onmessage = null;
this.onopen = null;
this.onclose = null;
this.connect();
}
connect() {
this.socket = new WebSocket(this.url);
this.socket.onopen = (event) => {
this.retries = 0;
if (this.onopen) this.onopen(event);
};
this.socket.onmessage = (event) => {
if (this.onmessage) this.onmessage(event);
};
this.socket.onclose = (event) => {
if (this.onclose) this.onclose(event);
if (this.retries < this.maxRetries) {
this.retries++;
const delay = this.retryDelay * Math.min(this.retries, 5);
setTimeout(() => this.connect(), delay);
}
};
this.socket.onerror = () => {
this.socket.close();
};
}
send(data) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(data);
}
}
}
The exponential backoff multiplier (Math.min(this.retries, 5)) caps the maximum delay at 5Γ the base delay, avoiding the thundering herd problem when a server comes back up after an outage and hundreds of clients reconnect simultaneously.
Heartbeat Ping/Pong to Detect Dead Connections
The WebSocket protocol has built-in ping/pong frames, but these are handled at the protocol level by most WebSocket libraries and aren't directly accessible from browser JavaScript. The practical approach for browser clients is to send application-level ping messages and expect a pong back.
Here's how to layer a heartbeat onto the reconnecting wrapper above:
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.maxRetries = options.maxRetries ?? 10;
this.retryDelay = options.retryDelay ?? 2000;
this.heartbeatInterval = options.heartbeatInterval ?? 25000;
this.retries = 0;
this.socket = null;
this._heartbeatTimer = null;
this.onmessage = null;
this.onopen = null;
this.onclose = null;
this.connect();
}
connect() {
this.socket = new WebSocket(this.url);
this.socket.onopen = (event) => {
this.retries = 0;
this._startHeartbeat();
if (this.onopen) this.onopen(event);
};
this.socket.onmessage = (event) => {
if (this.onmessage) this.onmessage(event);
};
this.socket.onclose = (event) => {
this._stopHeartbeat();
if (this.onclose) this.onclose(event);
if (this.retries < this.maxRetries) {
this.retries++;
const delay = this.retryDelay * Math.min(this.retries, 5);
setTimeout(() => this.connect(), delay);
}
};
this.socket.onerror = () => {
this.socket.close();
};
}
_startHeartbeat() {
this._heartbeatTimer = setInterval(() => {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: "ping" }));
}
}, this.heartbeatInterval);
}
_stopHeartbeat() {
clearInterval(this._heartbeatTimer);
}
send(data) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(data);
}
}
}
The heartbeat sends a ping every 25 seconds by default β safely below Nginx's 60-second default timeout even if you haven't changed it yet. Your server needs to handle the { type: "ping" } message and respond with { type: "pong" }. You can extend the class to track whether a pong was received and forcibly close a connection that's gone silent, which will trigger the reconnect logic.
Common Pitfalls and Gotchas
Multiple location blocks matching the same path. Nginx uses the most specific match, but if you have a catch-all location that handles all traffic and a separate one for /ws/, make sure the WebSocket-specific block is actually being hit. Add a temporary access_log line to confirm.
SSL termination adding another layer. If you terminate TLS at Nginx and proxy to a plain ws:// upstream, make sure you're not accidentally sending clients to ws:// instead of wss://. A mixed-content error in the browser will look like a connection failure but is actually a security policy block.
Cloudflare or another CDN in front of Nginx. Some CDN configurations have their own WebSocket timeout policies. Cloudflare, for instance, enforces its own timeout on Enterprise plans but allows configuration. If your connections are still dropping after fixing Nginx, check whether there's another proxy in the chain. This is the same class of problem you encounter when debugging CORS errors that only appear in production β the production network path has more layers than local dev.
The Connection header value must be lowercase "upgrade". The HTTP spec says header names are case-insensitive, but the value of the Connection header is not always normalized. Some Nginx versions and upstream servers are strict about upgrade vs Upgrade. Using the lowercase form in your config ("upgrade") is the safe default.
Load balancers resetting idle connections. If you're running Nginx behind a cloud load balancer (AWS ALB, GCP HTTPS load balancer), those have their own idle timeout settings. AWS ALB defaults to 60 seconds for idle connections. Fixing Nginx alone won't help if the load balancer in front of it is the one closing the connection. Check your load balancer's idle timeout and increase it to match your WebSocket timeout settings.
Worker processes not inheriting upstream config. If you define a keepalive on an upstream block but your proxy_pass directive references the server IP directly instead of the upstream name, the keepalive pool isn't used. Always reference the upstream block by name when you want keepalive to apply.
If you find your development and production behavior diverging in ways that are hard to reason about, it's worth reading about React useEffect firing differently in dev versus production β the underlying pattern of
Frequently Asked Questions
Why do WebSocket connections drop after exactly 60 seconds behind Nginx?
Nginx's default proxy_read_timeout is 60 seconds. If no data passes through the proxied connection within that window, Nginx closes it without sending a WebSocket close frame. Increasing proxy_read_timeout and adding a client-side heartbeat are the two fixes.
What Nginx directives are required for WebSocket proxying to work at all?
You need proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade, and proxy_set_header Connection "upgrade" in your location block. Without these, Nginx strips the upgrade headers and the server never switches protocols.
How do I prevent WebSocket reconnect storms when a server restarts?
Use exponential backoff in your client reconnect logic β multiply the retry delay by an increasing factor capped at a maximum value. This spreads out reconnect attempts so the server isn't hit by all clients simultaneously when it comes back online.
Does a WebSocket heartbeat actually prevent Nginx from dropping the connection?
Yes, because a heartbeat sends a small message at regular intervals β typically every 20β30 seconds β which resets Nginx's proxy_read_timeout counter. As long as the heartbeat interval is shorter than the timeout, the connection stays alive.
Can a CDN like Cloudflare cause WebSocket drops even after Nginx is correctly configured?
Yes. CDNs and cloud load balancers have their own idle connection timeouts that are independent of Nginx's settings. If your connections still drop after fixing Nginx, check the timeout configuration of any proxy sitting in front of it.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!