In this guide
HTTP is request/response: the client asks, the server answers. That works for loading pages and submitting forms. But what about a chat app, where the server needs to push new messages to the client the instant they arrive? Or a live dashboard where sensor data streams in continuously?
Polling (asking the server "anything new?" every second) works but wastes bandwidth and adds latency. WebSockets solve this properly: a single persistent connection where either side can send data at any time. This guide explains how WebSockets work and walks through building a working chat app.
What WebSockets are
WebSocket is a protocol (defined in RFC 6455) that provides full-duplex communication over a single TCP connection. The key word is full-duplex: after the connection is established, both client and server can send messages independently, without waiting for a request.
The connection starts as a normal HTTP request with an Upgrade: websocket header. If the server agrees, the connection "upgrades" from HTTP to the WebSocket protocol and stays open. Both sides can then send messages (text or binary) as frames over this persistent connection.
This is fundamentally different from HTTP:
| HTTP | WebSocket | |
|---|---|---|
| Direction | Client requests, server responds | Either side sends anytime |
| Connection | New connection per request (or keep-alive) | One persistent connection |
| Server push | Not possible (client must poll) | Natural |
| Overhead per message | Full HTTP headers each time | ~2 byte frame header |
| Use case | Pages, APIs, file transfer | Chat, live updates, gaming, collaboration |
When to use WebSockets (and when not to)
Use WebSockets when:
- The server needs to push data to the client in real time (chat messages, notifications, live scores)
- You need low-latency bidirectional communication (multiplayer games, collaborative editing)
- You're sending many small messages frequently (sensor dashboards, stock tickers)
Don't use WebSockets when:
- Simple request/response suffices (loading a page, submitting a form) — HTTP is simpler
- You need caching, or intermediaries (CDNs, proxies) to understand the traffic
- The data is one-directional server-to-client and infrequent — Server-Sent Events (SSE) are simpler
A common student mistake is using WebSockets for everything in a real-time project. Use WebSockets for the real-time channel and regular HTTP APIs for everything else (authentication, history loading, file uploads).
Building a chat app: the server
Using Node.js with the ws library (lightweight, no framework needed):
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
// Track connected clients
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
console.log('Client connected. Total:', clients.size);
// Broadcast received messages to all other clients
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
message.timestamp = Date.now();
const out = JSON.stringify(message);
clients.forEach((client) => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(out);
}
});
});
ws.on('close', () => {
clients.delete(ws);
console.log('Client disconnected. Total:', clients.size);
});
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
clients.delete(ws);
});
});
console.log('Chat server running on ws://localhost:8080');
The pattern is simple: on connection, add to the set. On message, broadcast to everyone else. On close or error, remove from the set. The readyState check before sending is essential — trying to send on a closed connection throws.
The client: browser JavaScript
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
console.log('Connected to chat server');
// Connection is ready - enable the send button
document.getElementById('sendBtn').disabled = false;
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
addMessageToUI(msg.username, msg.text, msg.timestamp);
};
ws.onclose = () => {
console.log('Disconnected');
// Show reconnect UI - don't just silently fail
showReconnectButton();
};
ws.onerror = (err) => {
console.error('Connection error');
};
function sendMessage(username, text) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ username, text }));
}
}
The browser's WebSocket API is built in — no library needed. The four event handlers (onopen, onmessage, onclose, onerror) cover the full lifecycle.
Handling disconnections: the part everyone skips
Networks drop. Phones switch from WiFi to cellular. Laptops sleep. A chat app that doesn't handle reconnection is a demo, not a product.
Client-side reconnection with backoff:
let ws;
let reconnectDelay = 1000;
function connect() {
ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
reconnectDelay = 1000; // reset on successful connection
console.log('Connected');
};
ws.onclose = () => {
console.log('Reconnecting in ' + reconnectDelay + 'ms');
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000); // exponential backoff, max 30s
};
ws.onmessage = (event) => {
// ... handle message ...
};
}
connect();
Exponential backoff (1s, 2s, 4s, 8s... capped at 30s) prevents a thousand clients from hammering your server simultaneously when it comes back online — the "thundering herd" problem.
Server-side heartbeat: if a client disconnects uncleanly (network cable pulled), the server may not notice for a long time. Implement ping/pong:
// Server: ping clients every 30 seconds, drop dead ones
setInterval(() => {
clients.forEach((ws) => {
if (ws.isAlive === false) {
clients.delete(ws);
return ws.terminate();
}
ws.isAlive = false;
ws.ping(); // client auto-responds with pong
});
}, 30000);
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
// ... rest of connection handling
});
Authentication with WebSockets
WebSockets don't have built-in authentication. The standard approach:
- Client authenticates via HTTP first (login API returns a token).
- Client includes the token when opening the WebSocket — either as a query parameter (
ws://host:8080?token=abc123) or in the initial HTTP headers. - Server validates the token during the
connectionevent and rejects unauthenticated clients immediately.
wss.on('connection', (ws, req) => {
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
const user = verifyToken(token); // your JWT/auth verification
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
ws.user = user; // attach user info for later use
// ... normal handling ...
});
Never trust the client. Validate the token on every new connection, and don't accept user identity from message payloads — use the authenticated ws.user instead.
Scaling beyond one server
The broadcast pattern above works on a single server. When you need multiple servers (or server restarts shouldn't disconnect everyone), you need a message broker between them:
- Redis Pub/Sub — the standard choice. Each server publishes incoming messages to Redis; each server subscribes and forwards to its connected clients. This is simple and handles most student-to-startup scale.
- Message queue (RabbitMQ, NATS) — for more complex routing.
The architecture: Client → WebSocket Server → Redis → WebSocket Server → Client. Each server only manages its own connections; Redis handles cross-server messaging.
WebSockets vs alternatives
| Technology | Direction | Use when |
|---|---|---|
| WebSockets | Bidirectional | Chat, games, collaboration |
| Server-Sent Events | Server → client only | Live feeds, notifications (simpler than WS) |
| Long polling | Simulated push | Legacy browser support needed |
| WebRTC | Peer-to-peer | Video/audio calls, file transfer between clients |
For a good comparison of request/response API design, see the REST API design guide. If your real-time app needs user accounts, the JWT authentication guide covers the token pattern used above. More web development concepts in the Web Development branch hub.