You've built a chat app, a live dashboard, or a multiplayer game — and HTTP requests feel wrong. You're polling every 5 seconds, burning through bandwidth, and the data is always a little stale. That's the problem WebSockets solve.
I've used WebSockets in production for real-time trading dashboards, collaborative editors, and notification systems. Here's everything I wish I knew from the start.
How WebSockets Work
WebSockets upgrade a standard HTTP connection into a persistent, full-duplex TCP connection. The flow looks like this:
- Client sends an HTTP
GETrequest with anUpgrade: websocketheader - Server responds with
101 Switching Protocols - The TCP connection stays open — both sides can send frames at any time
- No more HTTP overhead (no headers on every message)
Client → Server: GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Server → Client: HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=After the handshake, it's not HTTP anymore. It's a raw TCP socket with a lightweight framing protocol. Messages flow in both directions with minimal overhead — about 2-10 bytes of framing per message compared to hundreds of bytes of HTTP headers.
The Browser WebSocket API
The native browser API is straightforward:
const socket = new WebSocket('wss://example.com/chat');
socket.onopen = () => {
console.log('Connected');
socket.send(JSON.stringify({ type: 'join', room: 'general' }));
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
};
socket.onclose = (event) => {
console.log(`Closed: code=${event.code} reason=${event.reason}`);
// event.code tells you WHY it closed
// 1000 = normal, 1001 = going away, 1006 = abnormal (no close frame)
};Key things to know:
wss://is the secure version (always use this in production)socket.send()accepts strings,ArrayBuffer, orBlobsocket.readyStategives you the current state (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)- The
oncloseevent'scodefield is critical for debugging — see the full list of close codes
Using the ws Library (Node.js)
On the server side, the ws package is the standard choice. It's fast, well-maintained, and handles the HTTP upgrade for you.
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws, request) => {
console.log(`New client from ${request.socket.remoteAddress}`);
ws.on('message', (data) => {
// data is a Buffer by default
const message = JSON.parse(data.toString());
console.log('Received:', message);
// Echo back or broadcast
ws.send(JSON.stringify({ echo: message }));
});
ws.on('close', (code, reason) => {
console.log(`Client left: ${code}`);
});
ws.on('error', (err) => {
console.error('Client error:', err);
});
});For broadcasting to all connected clients:
function broadcast(wss, data) {
const payload = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
});
}Connection Lifecycle
Understanding the lifecycle prevents most bugs I see in production:
CONNECTING (0) → OPEN (1) → CLOSING (2) → CLOSED (3)Common mistakes:
- Calling
send()beforeonopenfires — message silently drops - Not handling
onclose— your app thinks it's still connected - Ignoring
readyState— always check before sending
A robust send function:
function safeSend(socket, data) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(typeof data === 'string' ? data : JSON.stringify(data));
return true;
}
console.warn('Socket not open, state:', socket.readyState);
return false;
}Reconnection Strategies
Connections drop. Network switches, server restarts, mobile signal loss — you need reconnection logic. Here's a production-grade approach with exponential backoff:
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.maxRetries = options.maxRetries ?? Infinity;
this.maxDelay = options.maxDelay ?? 30000;
this.baseDelay = options.baseDelay ?? 1000;
this.retries = 0;
this.socket = null;
this.manualClose = false;
this.handlers = {};
this.connect();
}
connect() {
this.socket = new WebSocket(this.url);
this.socket.onopen = (e) => {
this.retries = 0;
this.emit('open', e);
};
this.socket.onmessage = (e) => this.emit('message', e);
this.socket.onclose = (e) => {
if (!this.manualClose) {
this.reconnect();
}
this.emit('close', e);
};
this.socket.onerror = (e) => this.emit('error', e);
}
reconnect() {
if (this.retries >= this.maxRetries) return;
const delay = Math.min(
this.baseDelay * Math.pow(2, this.retries) + Math.random() * 1000,
this.maxDelay
);
this.retries++;
console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.retries})`);
setTimeout(() => this.connect(), delay);
}
send(data) {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(typeof data === 'string' ? data : JSON.stringify(data));
}
}
close() {
this.manualClose = true;
this.socket?.close();
}
on(event, handler) {
this.handlers[event] = this.handlers[event] || [];
this.handlers[event].push(handler);
}
emit(event, data) {
this.handlers[event]?.forEach((h) => h(data));
}
}The jitter (Math.random() * 1000) prevents the "thundering herd" problem — if 1000 clients all disconnect at once, they won't all reconnect simultaneously.
Heartbeat / Ping-Pong
Silent disconnections are a real problem. A connection can look open but the TCP link is dead (think: laptop suspended). The solution is heartbeats.
The WebSocket protocol has built-in ping/pong frames, but the browser API doesn't expose them. For browsers, implement application-level heartbeats:
// Client-side heartbeat
let heartbeatInterval;
let missedPings = 0;
const MAX_MISSED = 3;
function startHeartbeat(socket) {
missedPings = 0;
heartbeatInterval = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'ping' }));
missedPings++;
if (missedPings > MAX_MISSED) {
console.warn('Too many missed heartbeats, closing');
socket.close();
}
}
}, 30000); // every 30 seconds
}
function handleMessage(data) {
if (data.type === 'pong') {
missedPings = 0; // server is alive
return;
}
// handle normal messages
}On the server with ws, you can use the built-in ping/pong:
const wss = new WebSocketServer({
port: 8080,
clientTracking: true,
});
// Server-side ping every 30s
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});Binary Data
WebSockets handle binary frames natively. This is useful for file transfers, streaming audio, or game state:
// Sending binary data
const buffer = new Uint8Array([1, 2, 3, 4, 5]);
socket.send(buffer);
// Receiving — set binaryType first
socket.binaryType = 'arraybuffer'; // or 'blob'
socket.onmessage = (event) => {
if (typeof event.data === 'string') {
// text frame
} else {
// binary frame — event.data is an ArrayBuffer
const view = new DataView(event.data);
console.log('First byte:', view.getUint8(0));
}
};For file uploads over WebSocket, chunk the data:
async function sendFile(socket, file) {
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const buffer = await chunk.arrayBuffer();
socket.send(buffer);
}
socket.send(JSON.stringify({ type: 'file-complete', name: file.name }));
}WebSocket vs SSE vs HTTP Polling
This is the most common architectural question. Here's when to use each:
| WebSockets | SSE | HTTP Polling | |
|---|---|---|---|
| Direction | Full-duplex | Server → Client only | Client pulls |
| Protocol | ws:// / wss:// | HTTP | HTTP |
| Best for | Chat, games, collab | Notifications, feeds | Rarely the right choice |
| Reconnection | You build it | Built-in | N/A (stateless) |
| Binary | ✅ Native | ❌ Text only | ✅ Via HTTP |
| Proxy/Firewall | Sometimes blocked | HTTP (safe) | HTTP (safe) |
| Complexity | Medium-High | Low | Lowest |
Use WebSockets when:
- You need real-time bidirectional communication (chat, multiplayer, live trading)
- Message frequency is high (many messages per second)
- Low latency matters
Use SSE when:
- Data flows server → client only (notifications, live scores, activity feeds)
- You want simplicity — SSE is just a long-lived HTTP response
- You need automatic reconnection for free
// SSE is this simple
const source = new EventSource('/api/notifications');
source.onmessage = (event) => {
console.log(JSON.parse(event.data));
};
source.onerror = () => console.log('Reconnecting...');Use HTTP polling when:
- Nothing else works (restrictive corporate proxies)
- Updates are infrequent (every 30+ seconds)
- You need it working in 5 minutes and don't care about efficiency
Key Takeaways
- WebSockets upgrade HTTP to a persistent TCP connection — full-duplex, low overhead after handshake
- Always use
wss://in production — unencrypted WebSocket traffic is trivially intercepted - Build reconnection with exponential backoff and jitter — connections will drop, plan for it
- Implement heartbeats — detect dead connections before they cause bugs
- Use SSE for server → client only — it's simpler and handles reconnection for you
- Check
readyStatebefore sending — sending to a closed socket silently fails - The
wslibrary is the standard for Node.js servers — use it, don't roll your own parser
The WebSocket API is simple. Building a reliable real-time system on top of it is not. Handle reconnection, heartbeats, and connection state properly, and you'll save yourself from the most common production incidents I've seen.