Overview
WebSockets provide full-duplex communication over a single TCP connection. Unlike HTTP request-response, the server can push data to the client at any time. This tutorial explains the protocol, then builds a working chat server and client using Node.js and the ws library.
WebSocket vs HTTP Polling
| Approach | Latency | Server load | Use case |
|---|---|---|---|
| Short polling | High (interval-bound) | High (many empty responses) | Legacy systems |
| Long polling | Medium | Medium | Fallback for older clients |
| Server-Sent Events | Low | Low | One-way server push |
| WebSocket | Lowest | Low | Chat, games, live collaboration |
How the Handshake Works
A WebSocket connection begins as an HTTP request with an Upgrade header:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
The server responds with 101 Switching Protocols, and the connection is upgraded. From that point on, both sides exchange frames in either direction.
Step 1: Initialize the Server
mkdir ws-chat
cd ws-chat
npm init -y
npm install ws
Step 2: Create the WebSocket Server
// server.js
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set();
wss.on('connection', (ws, req) => {
const ip = req.socket.remoteAddress;
console.log(`Client connected from ${ip}`);
clients.add(ws);
ws.send(JSON.stringify({ type: 'system', message: 'Welcome to the chat' }));
ws.on('message', (raw) => {
let payload;
try {
payload = JSON.parse(raw);
} catch (err) {
return ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
}
const outbound = JSON.stringify({
type: 'chat',
message: payload.message,
timestamp: Date.now(),
});
for (const client of clients) {
if (client.readyState === ws.OPEN) {
client.send(outbound);
}
}
});
ws.on('close', () => {
clients.delete(ws);
console.log('Client disconnected');
});
ws.on('error', (err) => console.error('Socket error:', err.message));
});
console.log('WebSocket server listening on ws://localhost:8080');
Run it:
node server.js
Step 3: Build the Browser Client
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>WebSocket Chat</title>
</head>
<body>
<ul id="messages"></ul>
<input id="input" autocomplete="off" />
<button id="send">Send</button>
<script>
const socket = new WebSocket('ws://localhost:8080');
const messages = document.getElementById('messages');
const input = document.getElementById('input');
socket.addEventListener('open', () => {
console.log('Connected');
});
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
const li = document.createElement('li');
li.textContent = data.type === 'chat'
? `${new Date(data.timestamp).toLocaleTimeString()}: ${data.message}`
: data.message;
messages.appendChild(li);
});
socket.addEventListener('close', () => {
console.log('Disconnected');
});
function send() {
if (!input.value) return;
socket.send(JSON.stringify({ message: input.value }));
input.value = '';
}
document.getElementById('send').addEventListener('click', send);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
</script>
</body>
</html>
Heartbeats and Reconnection
Networks drop idle connections. Production clients should ping the server periodically and reconnect on close.
// Server-side ping
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
}
}, 30000);
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
wss.on('close', () => clearInterval(interval));
// Client-side reconnection with backoff
let retryDelay = 1000;
function connect() {
const socket = new WebSocket('ws://localhost:8080');
socket.addEventListener('open', () => { retryDelay = 1000; });
socket.addEventListener('close', () => {
setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000);
});
}
Scaling Beyond a Single Server
A single Node.js process holds the set of connected clients in memory. When you scale horizontally, add a message broker so that a message received by one instance reaches clients on another:
| Broker | Notes |
|---|---|
| Redis Pub/Sub | Simple, low latency, no persistence |
| NATS | Lightweight, high throughput |
| Kafka | Durable, replayable, heavier operation |
Use socket.io or a managed service if you need sticky sessions, rooms, and fallbacks out of the box.
Security Checklist
- Always use
wss://in production. Plainws://transmits in cleartext. - Validate the
Originheader during the handshake to prevent cross-site WebSocket hijacking. - Authenticate after connection opens, typically by sending a JWT as the first message.
- Rate-limit messages per client to prevent flooding.
- Set a maximum message size to avoid memory exhaustion.
Common Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| Connection closes after 60 seconds | Proxy idle timeout | Enable heartbeat pings or raise proxy timeout |
| Browser throws 403 on upgrade | Proxy or load balancer strips upgrade headers | Configure Nginx with proxy_set_header Upgrade |
| Messages lost on reconnect | No delivery guarantee | Add sequence numbers and replay from a store |
