WebSocket disconnects after 60 seconds idle behind Nginx and a load balancer
Our live support chat dropped every connection that stayed quiet for a minute, with close code 1006 and no server error. Nginx's 60-second proxy_read_timeout and the load balancer idle timeout were closing idle sockets. A heartbeat plus proxy tuning fixed it.
We run a live support chat for an online pharmacy. Customers open the widget, type a question and wait for an agent. In April, agents started complaining that customers "vanished" mid-conversation: the agent typed a reply and the customer never got it. Our client logs had the pattern within an hour of adding instrumentation:
[chat] socket closed code=1006 reason="" wasClean=false after=60.4s idle=60.0s
[chat] reconnecting (attempt 1)
Code 1006 means the connection was closed abnormally, without a WebSocket close frame. Every case had something in common: nothing had been sent on the socket for almost exactly 60 seconds. Active conversations never dropped. Customers waiting for an agent always did.
Reproducing it
We opened a socket from the terminal with websocat and simply waited:
time websocat wss://chat.example.com/ws
# (no input for a minute, then websocat exits with an error because the
# connection ends without a close frame)
# real 1m0.31s
Exactly one minute, every time. Sending a message every 30 seconds kept it alive indefinitely.
What we ruled out
- The Node.js server closing sockets. We logged every
closeevent with its code; the server saw a TCP reset, not a close it had initiated. Thewslibrary has no idle timeout by default. - Clients on flaky mobile networks. Desktop users on office Wi-Fi were affected just as often.
- The browser throttling background tabs. Chrome throttles timers in hidden tabs, but the socket stayed open in hidden tabs when we connected directly to the Node process on the internal network.
Root cause
Two proxies sat between the browser and Node, and both had a 60-second idle limit. An AWS Application
Load Balancer terminated TLS with its default idle timeout of 60 seconds, and behind it an Nginx
instance proxied /ws to the Node app with the default proxy_read_timeout of 60 seconds. When a
customer was waiting quietly for an agent, no frames crossed the connection, so Nginx decided the
upstream had stopped responding and closed the connection. The ALB would have done the same a moment
later. Neither proxy sends a WebSocket close frame, so the browser reported 1006.
What is happening under the hood
A WebSocket starts as an HTTP/1.1 request with Upgrade: websocket and Connection: Upgrade. If the
server answers 101 Switching Protocols, the TCP connection becomes a bidirectional frame stream. To
an HTTP proxy, that is just a long-lived connection with occasional bytes on it.
Nginx applies proxy_read_timeout between two successive reads from the upstream, and
proxy_send_timeout between two writes to it. Both default to 60 seconds. For normal HTTP that means
"the backend took too long to answer"; for a WebSocket it means "nobody talked for a minute". Load
balancers have similar idle timeouts: on an ALB the connection idle timeout defaults to 60 seconds and
can be raised up to 4,000 seconds.
The protocol has a built-in answer. RFC 6455 defines ping and pong control frames; the receiver of a ping must answer with a pong. Browsers answer pings automatically, but the browser WebSocket API gives JavaScript no way to send pings, so the server has to drive the heartbeat.
Environment
| Component | Version / value |
|---|---|
| Client | Browser WebSocket API (Chrome 135, Safari 18) |
| Load balancer | AWS Application Load Balancer, idle timeout 60 s (default) |
| Reverse proxy | Nginx 1.26.3, proxy_read_timeout default 60 s |
| Server | Node.js 22.14, ws 8.18 |
| Hosting | EC2 behind ALB, Nginx and Node on the same instance |
Minimal reproduction
# Works for the upgrade, but idle connections die after 60 seconds
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
// server.js: no heartbeat, nothing written while the customer waits
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => ws.on('message', (data) => routeToAgent(ws, data)));
Who is affected
Any WebSocket (or Server-Sent Events, or long polling) application behind Nginx, HAProxy, an ALB,
Azure Application Gateway, Cloudflare or a corporate proxy, where connections can stay silent longer
than the shortest idle timeout on the path. If you also forget the Upgrade and Connection headers
in Nginx, the handshake fails outright; libraries such as Socket.IO then fall back to HTTP long
polling, which hides the problem as higher latency instead of disconnects.
The fix
We changed three layers, because raising timeouts alone only moves the cliff.
1. Server-side heartbeat. Every 25 seconds the server pings each client; a client that has not answered the previous ping is terminated. This keeps intermediaries from seeing an idle connection and also cleans up dead peers that vanished without a TCP close.
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const HEARTBEAT_MS = 25_000;
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (data) => routeToAgent(ws, data));
});
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate(); // missed a whole heartbeat cycle
continue;
}
ws.isAlive = false;
ws.ping(); // browsers reply with pong automatically
}
}, HEARTBEAT_MS);
wss.on('close', () => clearInterval(interval));
2. Proxy timeouts that match the heartbeat. Nginx now waits up to 5 minutes, comfortably above the
25-second heartbeat, and uses a map so non-WebSocket requests on the same location do not get a
forced Connection: upgrade header:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
# ...
location /ws {
proxy_pass http://127.0.0.1:8080;
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 300s;
proxy_send_timeout 300s;
}
}
On the ALB we raised the idle timeout to 300 seconds:
aws elbv2 modify-load-balancer-attributes \
--load-balancer-arn arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/app/chat/0123456789abcdef \
--attributes Key=idle_timeout.timeout_seconds,Value=300
3. Client reconnect with backoff and jitter. Networks still fail, deploys still restart servers. The widget now reconnects with exponential backoff capped at 30 seconds, plus random jitter, and asks the server for messages it missed using the last message ID it saw.
let attempt = 0;
function connect() {
const ws = new WebSocket('wss://chat.example.com/ws');
ws.onopen = () => { attempt = 0; ws.send(JSON.stringify({ type: 'resume', after: lastMessageId })); };
ws.onclose = () => {
const delay = Math.min(30_000, 1_000 * 2 ** attempt++) * (0.5 + Math.random() / 2);
setTimeout(connect, delay);
};
}
Alternatives and trade-offs
- Only raise timeouts to an hour. Fewer drops, but dead connections linger on the server, and any other proxy on the path (a customer's corporate proxy, for example) still enforces its own limit.
- Application-level ping messages sent by the client. Useful when you need the client to detect a dead server quickly, since browsers do not expose protocol pings. We added a lightweight one later for the "agent is offline" indicator.
- Server-Sent Events. Simpler for one-way streams, but has the same idle timeout issue and needs comment lines as keepalives.
How to test the fix
# Stay idle for 10 minutes; the connection should survive
timeout 600 websocat -v wss://chat.example.com/ws; echo "exit=$?"
An exit code of 124 means timeout killed it after 10 minutes, so the socket stayed open the whole
time. We also added an integration test that opens a socket through the staging ALB, waits 180 seconds
and asserts that a message sent from the server afterwards is received.
Confirming in production
- The share of chat sessions with at least one 1006 close dropped from 41 percent to 1.3 percent, and the remainder correlated with mobile network changes.
- Agents' "customer disconnected" tickets went from about 30 per day to two in the first week.
- Nginx
$request_timefor/wsrequests in the access log, which equals connection lifetime, now shows a long tail of 20 to 40 minute sessions instead of a hard spike at 60 seconds. - Server-side,
wss.clients.sizestopped creeping up overnight, because dead peers are terminated by the heartbeat instead of lingering.
Regression guard
- The 180-second idle integration test runs in the deploy pipeline against staging.
- The ALB idle timeout and Nginx timeouts are in Terraform and an Nginx config template, with a comment pointing to the heartbeat constant so they are changed together.
- A dashboard panel for close codes reported by clients, with an alert if 1006 exceeds 5 percent of sessions per hour.
Security implications
Longer timeouts increase how long an idle connection can hold server resources, which makes
connection-exhaustion denial of service slightly cheaper. The heartbeat counters that by terminating
unresponsive peers, and we cap connections per IP in Nginx with limit_conn. We also confirmed that
the WebSocket handshake still checks the session cookie and the Origin header, since a long-lived
socket that skipped authentication would be far worse than a dropped one.
Prevention checklist
- Send server-side pings more often than the shortest idle timeout on the path.
- Set Nginx
proxy_read_timeoutand load balancer idle timeouts deliberately for WebSocket routes. - Use the
map $http_upgradepattern for theConnectionheader. - Build reconnect with backoff, jitter and message resume into every client.
- Log close codes on the client; 1006 is a network-path signal, not an app error.
References
- RFC 6455, The WebSocket Protocol (ping and pong frames) — https://www.rfc-editor.org/rfc/rfc6455#section-5.5.2
- Nginx: WebSocket proxying — https://nginx.org/en/docs/http/websocket.html
- Nginx:
proxy_read_timeout— https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout - AWS: Application Load Balancer connection idle timeout — https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#connection-idle-timeout
- ws library: how to detect and close broken connections — https://github.com/websockets/ws#how-to-detect-and-close-broken-connections
- MDN: CloseEvent codes — https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
Add a label
Embed this post
<iframe src="https://hibug.me/websocket-disconnects-after-60-seconds-proxy" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Related Stories
Streaming LLM tool calls: Unexpected end of JSON input from partial arguments
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.