If your WebSocket or Server-Sent Events connection dies at a suspiciously round interval (60 seconds is the classic, 55 or 30 also show up) and the browser reports close code 1006 with no reason, something between your client and your server has an idle timeout and is cutting the TCP connection when no bytes flow. The fix is not a bigger timeout; it's a server-initiated heartbeat that keeps bytes moving at a shorter interval than the strictest hop, plus a client that reconnects without panicking. This post walks through how to prove which hop is killing you and what the fix looks like on each.
What does close code 1006 actually mean?
1006 is defined by the WebSocket spec as "abnormal closure": the connection ended without either side sending a close frame. Your server did not say goodbye, and neither did the browser. That is precisely the signature of a middlebox dropping the TCP socket: an L7 proxy tracking idle time decides the connection is dead, closes both sides, and neither endpoint gets a WebSocket-level close.
In the browser console it looks like this:
WebSocket connection to 'wss://app.example.com/ws' failed:
WebSocket is closed before the connection is established.
or, more commonly on an already-open socket, an onclose event with event.code === 1006 and event.wasClean === false. On the Node side you often see nothing at all, or a plain ECONNRESET on the underlying socket. Local development works perfectly because there is no proxy between localhost:3000 and your browser.
The takeaway: a 1006 on a regular interval is a timer in the network path, not a bug in your message handling.
Which hop has the timer?
Every layer between the browser and your process can have its own idle clock, and only application bytes reset it. TCP keepalive probes do not help here: they are exchanged between adjacent TCP peers only, and an L7 proxy terminates TCP on both sides, so a keepalive from your server never reaches the load balancer's "was there data?" counter.
The usual suspects, as of mid-2026 (defaults change, so verify against your provider's current docs):
| Hop | Default idle behavior | Where to change it |
|---|---|---|
nginx (proxy_pass) |
Closes if the upstream sends nothing for proxy_read_timeout (60s) |
proxy_read_timeout / proxy_send_timeout in the location block |
| ingress-nginx (Kubernetes) | Same 60s defaults, inherited from nginx |
nginx.ingress.kubernetes.io/proxy-read-timeout annotation |
| AWS Application Load Balancer | Idle timeout, 60s default, applies to WebSockets | Load balancer attribute idle_timeout.timeout_seconds
|
| Google Cloud external HTTP(S) LB | Backend service timeout (30s default) is treated as the maximum lifetime of a WebSocket, idle or not | Backend service timeoutSec
|
| Heroku router | Terminates after 55s with no data in either direction | Not configurable; heartbeat is the only option |
| Cloudflare (proxied) | WebSockets supported; timeouts depend on plan and whether the origin responds | Check the current docs for your plan; heartbeat regardless |
The Google Cloud row is the one that surprises people. Raising it to 3600 does not make the problem go away; it just makes your sockets die once an hour instead of every 30 seconds, which is a harder bug to notice and reproduce. Your client must handle reconnection no matter what, and the heartbeat only prevents the idle kills.
To identify the hop quickly: open a socket, send nothing, and time the close. 60s points at nginx or ALB. 55s is Heroku. 30s that ignores your heartbeat entirely is the GCP max-lifetime behavior. If it dies at a different interval after you add a heartbeat, you have found a second timer stacked behind the first.
The takeaway: measure the time-to-death with a silent connection before touching any config, because the interval tells you which layer to look at.
How do you keep the connection alive without relying on the client?
Send the heartbeat from the server. Client-side timers are unreliable: Chrome throttles setInterval in background tabs (intensive throttling aligns timers to once per minute after a tab has been hidden for a while, as of current stable), which is exactly long enough to trip a 60-second proxy timeout. The server has no such constraint and it's the only party that also needs to detect dead clients for cleanup.
With the ws library in Node, the pattern from its own README is a ping every N seconds and a terminate() for any client that did not pong since the last round:
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 3000 });
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
const HEARTBEAT_MS = 25_000; // must be < the strictest proxy idle timeout
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
ws.terminate(); // no pong since last tick: assume the peer is gone
continue;
}
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_MS);
wss.on('close', () => clearInterval(interval));
Two details matter. First, the ping frame counts as application data to every L7 proxy in the path, so it resets each idle clock. Second, terminate() rather than close() on the unresponsive branch: a peer that cannot pong will not complete a close handshake either, and close() would leave a zombie for another full interval.
Pick the interval as roughly half of the shortest timeout you found in the table. 25 seconds is a safe default under a 55–60 second limit; it's also what Socket.IO ships as its pingInterval default, which is why Socket.IO users rarely hit this class of bug until they move to a stricter proxy. If you'd rather not own any of this, Socket.IO is the library that handles heartbeats, reconnection with backoff, and transport fallback out of the box; the cost is a custom protocol on top of WebSockets, so non-JS clients need a Socket.IO client library rather than a plain WebSocket.
For Server-Sent Events the same principle applies, and it's even simpler because SSE has a comment syntax that clients ignore:
// Express handler for an SSE stream
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('retry: 3000\n\n'); // tell EventSource how long to wait before reconnecting
const keepalive = setInterval(() => res.write(': keepalive\n\n'), 25_000);
req.on('close', () => clearInterval(keepalive));
});
A line starting with : is a comment per the SSE spec; EventSource discards it but the proxy sees bytes. Note the retry: field: EventSource reconnects on its own, and this lets you set the delay instead of relying on the browser default.
The takeaway: a server-sent ping every 25 seconds fixes the idle-timeout class of disconnect on every proxy in the table except the one that enforces a maximum lifetime.
Should you raise the proxy timeout instead?
Raise it as well, not instead. Heartbeats solve the idle problem, but a 60-second proxy_read_timeout is also what kills legitimately long silent responses like a slow LLM completion or a large export, so it's worth loosening for the specific WebSocket location:
location /ws {
proxy_pass http://app_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
Keep the large value scoped to the upgrade path. Applying it globally means a stuck upstream on a normal HTTP request can hold a worker connection for an hour.
The one-line rule: heartbeats are mandatory and portable; timeout increases are a per-environment optimization that you'll forget to carry over to the next hosting provider.
How should the client reconnect?
The browser's WebSocket does not reconnect on its own (EventSource does). A minimal client needs exponential backoff with jitter so that a proxy restart doesn't turn ten thousand clients into a synchronized reconnect storm:
function connect(url, { onMessage }) {
let attempt = 0;
let ws;
function open() {
ws = new WebSocket(url);
ws.onopen = () => { attempt = 0; };
ws.onmessage = (e) => onMessage(JSON.parse(e.data));
ws.onclose = (e) => {
const base = Math.min(30_000, 1000 * 2 ** attempt);
const delay = base / 2 + Math.random() * (base / 2); // jitter
attempt += 1;
setTimeout(open, delay);
};
ws.onerror = () => ws.close(); // ensure onclose fires and schedules a retry
}
open();
return () => ws && ws.close(1000, 'client shutdown');
}
What this does not solve is missed messages during the gap. If a client must not lose events, you need a resumable stream: either a Last-Event-ID (native to SSE) or a sequence number you send on reconnect so the server can replay from its buffer. That is real work, and it's the point at which a managed service earns its fee. If you want the managed version of this, Ably is the one that handles connection state recovery and message replay across reconnects without you building the buffer; the trade-off is per-message and per-connection pricing that makes chatty, high-fanout workloads expensive compared to a socket you own, so run the math on your peak message rate before committing.
The takeaway: reconnection with jitter is table stakes, and replay after reconnect is where you decide between building a buffer and buying one.
FAQ
Why does my WebSocket close after 60 seconds behind nginx?
Because nginx's proxy_read_timeout defaults to 60 seconds and closes the upstream connection when no data has been transmitted in that window. Send a ping from the server at least every 30 seconds, and raise proxy_read_timeout on the WebSocket location if you also have long silent responses.
Does TCP keepalive prevent WebSocket idle timeouts on a load balancer?
No. TCP keepalive probes only travel between adjacent TCP peers, and an L7 load balancer terminates TCP on each side, so its idle timer only resets on application data. Use WebSocket ping frames or SSE comment lines instead.
What is the difference between WebSocket close code 1006 and 1001?
1001 means one side sent a close frame because it is "going away" (page navigation, server shutdown). 1006 means the connection dropped with no close frame at all, which almost always indicates a proxy, load balancer, or network cut rather than either application.
Bottom line
If you own a WebSocket or SSE endpoint and it runs behind anything other than your own process, ship a server-initiated heartbeat at about 25 seconds and a client that reconnects with jittered backoff; do that before adjusting a single timeout. Then raise the proxy's read timeout on the upgrade path so slow-but-legitimate silences survive, and check whether your load balancer enforces a maximum connection lifetime (Google Cloud does by default) that no heartbeat can defeat. Teams that also need guaranteed delivery across reconnects should either budget the time for a replay buffer or pay a managed realtime provider for one.
Top comments (0)