A Gentle Introduction to WebSockets
HTTP is request-response: the client asks, the server answers, the connection's job is done. That model breaks down the moment the server needs to tell the client something without being asked first — a chat message arriving, a stock price ticking, a build finishing. WebSockets exist to fix exactly that: a single long-lived connection where either side can send a message to the other, at any time, with none of HTTP's per-request overhead.
Why not just poll?
Before WebSockets, the standard workaround was polling: the client asks "anything new?" every few seconds.
setInterval(async () => {
const response = await fetch('/api/messages/latest');
const messages = await response.json();
renderNewMessages(messages);
}, 3000);
This works, but it's a bad trade in both directions. Poll too slowly and updates feel laggy; poll too fast and you're making mostly-wasted HTTP requests — each with its own TCP handshake overhead (or connection reuse complexity), headers, and server-side request handling — just to usually hear "nothing changed." A WebSocket connection is opened once and then just sends the actual data, in either direction, the moment it exists.
The handshake: still HTTP, just briefly
A WebSocket connection starts as a normal HTTP request with an Upgrade header. The server agrees to switch protocols, and from that point on the same TCP connection carries the WebSocket framing instead of HTTP.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
You never construct this by hand — the browser's WebSocket API and server-side libraries handle the handshake. It's worth knowing it exists, though: this is why a WebSocket URL uses ws:// or wss:// (the secure version, over TLS) rather than http://, and why a WebSocket connection can be blocked by the same things that block an HTTP request — CORS-adjacent origin checks, proxies that don't support Upgrade, corporate firewalls.
The client side
const socket = new WebSocket('wss://example.com/chat');
socket.addEventListener('open', () => {
console.log('connected');
socket.send(JSON.stringify({ type: 'join', room: 'general' }));
});
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log('received:', data);
});
socket.addEventListener('close', (event) => {
console.log(`connection closed: ${event.code} ${event.reason}`);
});
socket.addEventListener('error', (event) => {
console.error('socket error', event);
});
Four events cover the whole lifecycle: open once the handshake completes, message for every incoming frame, close when the connection ends (by either side), and error for transport-level failures. Everything you send and receive is either text or binary — there's no built-in request/response pairing, so if you need to correlate a reply with a specific message you sent, that's on your application protocol (typically: include an id in the payload).
A minimal server, with Node's ws library
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set();
wss.on('connection', (socket) => {
clients.add(socket);
console.log(`client connected, ${clients.size} total`);
socket.on('message', (raw) => {
const message = JSON.parse(raw.toString());
// broadcast to everyone except the sender
for (const client of clients) {
if (client !== socket && client.readyState === client.OPEN) {
client.send(JSON.stringify(message));
}
}
});
socket.on('close', () => {
clients.delete(socket);
console.log(`client disconnected, ${clients.size} remaining`);
});
});
This is a complete broadcast chat server: every connected client gets forwarded every message sent by anyone else. The Set of open sockets is the server's entire notion of "who's listening" — no polling, no comparing timestamps, just push.
Reconnection is your job, not the browser's
If the connection drops — server restart, network blip, laptop going to sleep — the WebSocket object doesn't reconnect itself. You need to handle that explicitly, usually with exponential backoff so a server outage doesn't get hammered by every client reconnecting in lockstep the instant it comes back.
function connectWithRetry(url, { onMessage }) {
let attempt = 0;
function connect() {
const socket = new WebSocket(url);
socket.addEventListener('open', () => {
attempt = 0; // reset backoff once we're actually connected
});
socket.addEventListener('message', onMessage);
socket.addEventListener('close', () => {
const delay = Math.min(1000 * 2 ** attempt, 30000);
attempt++;
setTimeout(connect, delay);
});
}
connect();
}
Delays double each attempt (1s, 2s, 4s, 8s...) up to a 30-second ceiling, and reset back to zero once a connection actually succeeds. Production systems usually add random jitter on top of this so that many clients reconnecting after the same outage don't all retry at the exact same moments.
When WebSockets are the wrong tool
They're not a universal upgrade over HTTP. If the client only ever needs occasional server-initiated updates and can tolerate a few seconds of latency, Server-Sent Events (SSE) are simpler — one-directional (server to client only), automatic reconnection built in, and just a regular HTTP response the browser knows how to keep open. If communication is genuinely just request-response, plain HTTP with fetch is simpler still and gets you caching, retries, and standard HTTP semantics for free. Reach for a WebSocket specifically when you need low-latency, bidirectional communication — chat, multiplayer state, collaborative editing, live dashboards where the server pushes as often as the client asks.