WebSocket Close Codes Explained
When a WebSocket connection closes, both sides exchange a close frame containing a numeric status code and an optional reason string. These codes are your first diagnostic signal:
| Code | Name | Common Cause |
|---|---|---|
| `1000` | Normal Closure | Intentional close by either party |
| `1001` | Going Away | Page navigation, server restart |
| `1006` | Abnormal Closure | TCP closed without close frame (network drop) |
| `1007` | Invalid Frame | Malformed UTF-8 in text frame |
| `1008` | Policy Violation | Auth failure, rate limit exceeded |
| `1009` | Message Too Big | Frame exceeds server's max size |
| `1011` | Internal Error | Unhandled server exception |
| `1012` | Service Restart | Server-initiated rolling restart |
| `1013` | Try Again Later | Server temporarily overloaded |
Code 1006 is the most common in production and the hardest to debug — it means the TCP connection closed without a proper WebSocket close handshake, often due to a proxy or firewall timeout.
Common Disconnect Causes
Proxy idle timeout: Nginx, AWS ALB, and Cloudflare all have default idle timeouts (Nginx: 60s, AWS ALB: 60s, Cloudflare: 100s). If no data is sent in that window, the proxy closes the TCP connection — the WebSocket layer sees this as a 1006 abnormal closure.
Server process restart: a rolling deploy or crash closes all active WebSocket connections, producing 1001 or 1006.
Client sleep/hibernate: mobile devices suspend network activity. When the device wakes, the server-side socket may have been reaped.
Memory pressure: if the server runs out of memory, processes are killed and connections closed abruptly (1006).
Ping/Pong and Heartbeats
The WebSocket protocol defines ping and pong control frames (RFC 6455 section 5.5). A server sends a ping frame; the client must respond with a pong frame. This:
- Proves the connection is still alive
- Resets the proxy idle timeout
- Allows detection of half-open connections
# Server-side heartbeat (websockets library)
import asyncio, websockets
async def handler(websocket):
async for message in websocket:
await websocket.send(message)
async def main():
async with websockets.serve(
handler, 'localhost', 8765,
ping_interval=20, # send ping every 20s
ping_timeout=10, # close if no pong within 10s
):
await asyncio.Future()
Set ping_interval to roughly half the shortest proxy timeout in your stack. For a 60s ALB timeout, use ping_interval=25.
Proxy and Load Balancer Timeouts
Nginx:
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_read_timeout 3600s; # 1 hour
proxy_send_timeout 3600s;
}
AWS ALB: increase idle timeout under Load Balancer Attributes to a value larger than your heartbeat interval.
Cloudflare: WebSocket connections are supported on all plans, with a 100-second idle timeout. Use heartbeats shorter than 100 seconds.
Reconnection Strategies
Clients should always implement automatic reconnection:
function createWebSocket(url) {
let ws;
let reconnectDelay = 1000;
function connect() {
ws = new WebSocket(url);
ws.onopen = () => { reconnectDelay = 1000; };
ws.onclose = (event) => {
if (event.code !== 1000) {
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
}
};
}
connect();
return { send: (msg) => ws.send(msg) };
}
Browser DevTools for WebSocket
In Chrome: DevTools → Network tab → filter by WS. Click a WebSocket connection to see:
- Headers — upgrade handshake, Sec-WebSocket-Key
- Messages — all frames with timestamps and direction (↑ sent, ↓ received)
- Close code — visible when the connection closes
Monitoring WebSocket Health
Track these metrics in production:
- Active connection count
- Disconnect rate per close code (1006 spikes indicate proxy issues)
- Message latency (ping round-trip time)
- Reconnection rate (high rate suggests persistent instability)