WebSocket Fundamentals for Backend Developers
When HTTP polling isn't enough — a practical guide to WebSocket connections, message patterns, and production concerns like reconnection and scaling.

HTTP is request-response. The client asks, the server answers. For real-time features — live notifications, collaborative editing, chat, streaming data — this model forces awkward workarounds like long polling. WebSockets solve this with a persistent, bidirectional connection where either side can send data at any time.
When WebSockets Make Sense
Not every real-time feature needs WebSockets. Use them when the server needs to push data to clients without being asked.
| Pattern | HTTP works | WebSockets better |
|---|---|---|
| Dashboard refreshing every 30s | ✅ Polling is fine | Overkill |
| Live chat messages | Polling wastes bandwidth | ✅ Instant delivery |
| Collaborative document editing | Not feasible | ✅ Required |
| Stock price ticker | ✅ SSE is simpler | ✅ If bidirectional needed |
| Notification bell with count | ✅ Polling or SSE | ✅ If already using WS |
Server-Sent Events (SSE) handle the "server pushes, client listens" pattern more simply than WebSockets. Only reach for WebSockets when you need bidirectional communication.
Basic Server Setup
import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "http";
const server = createServer();
const wss = new WebSocketServer({ server });
wss.on("connection", (ws: WebSocket, req) => {
const userId = authenticateFromRequest(req);
if (!userId) {
ws.close(4001, "Unauthorized");
return;
}
console.log(`Client connected: ${userId}`);
ws.on("message", (data) => {
const message = JSON.parse(data.toString());
handleMessage(ws, userId, message);
});
ws.on("close", (code, reason) => {
console.log(`Client disconnected: ${userId}, code: ${code}`);
cleanupConnection(userId);
});
ws.on("error", (error) => {
console.error(`WebSocket error for ${userId}:`, error.message);
});
// Send initial state
ws.send(JSON.stringify({ type: "connected", userId }));
});
server.listen(3001);Always authenticate during the connection handshake, not after. An unauthenticated WebSocket connection is an open door.
Message Protocol Design
Define a typed message protocol upfront. Without it, you end up parsing unstructured blobs and hoping for the best.
// ❌ Untyped messages — no contract, no validation
ws.send("hello");
ws.send(JSON.stringify({ action: "send", text: "hi" }));
ws.send(JSON.stringify({ type: "msg", body: "hello" }));
// ✅ Typed message protocol — both sides know the contract
type ClientMessage =
| { type: "chat:send"; roomId: string; content: string }
| { type: "chat:typing"; roomId: string }
| { type: "presence:update"; status: "online" | "away" };
type ServerMessage =
| { type: "chat:new"; roomId: string; message: ChatMessage }
| { type: "chat:typing"; roomId: string; userId: string }
| { type: "presence:changed"; userId: string; status: string }
| { type: "error"; code: string; message: string };
function handleMessage(ws: WebSocket, userId: string, msg: ClientMessage) {
switch (msg.type) {
case "chat:send":
broadcastToRoom(msg.roomId, {
type: "chat:new",
roomId: msg.roomId,
message: { userId, content: msg.content, timestamp: Date.now() },
});
break;
case "chat:typing":
broadcastToRoom(msg.roomId, {
type: "chat:typing",
roomId: msg.roomId,
userId,
});
break;
}
}Namespace message types with colons (chat:send, presence:update). It keeps the protocol organized as it grows.
Connection Management
Track connected clients in a map for targeted messaging and cleanup.
const connections = new Map<string, WebSocket>();
function registerConnection(userId: string, ws: WebSocket) {
// Close existing connection if user reconnects
const existing = connections.get(userId);
if (existing?.readyState === WebSocket.OPEN) {
existing.close(4000, "Replaced by new connection");
}
connections.set(userId, ws);
}
function sendToUser(userId: string, message: ServerMessage) {
const ws = connections.get(userId);
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
function broadcastToRoom(roomId: string, message: ServerMessage) {
const members = getRoomMembers(roomId);
const payload = JSON.stringify(message);
for (const userId of members) {
const ws = connections.get(userId);
if (ws?.readyState === WebSocket.OPEN) {
ws.send(payload);
}
}
}Client-Side Reconnection
Connections drop. Networks switch. Servers restart. The client must handle reconnection gracefully.
// ✅ Reconnecting WebSocket client with exponential backoff
function createReliableSocket(url: string) {
let ws: WebSocket | null = null;
let retryCount = 0;
const maxRetryDelay = 30000;
function connect() {
ws = new WebSocket(url);
ws.onopen = () => {
retryCount = 0; // Reset backoff on successful connection
console.log("WebSocket connected");
};
ws.onclose = (event) => {
if (event.code === 4001) return; // Auth failure — don't retry
const delay = Math.min(1000 * 2 ** retryCount, maxRetryDelay);
retryCount++;
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(connect, delay);
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
handleServerMessage(message);
};
}
connect();
return {
send: (msg: ClientMessage) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
},
};
}Exponential backoff prevents thundering herds when the server restarts and all clients try to reconnect simultaneously.
Heartbeats and Dead Connections
TCP connections can silently die (network switch, laptop sleep). Heartbeats detect dead connections so the server can clean up resources.
const HEARTBEAT_INTERVAL = 30000;
const HEARTBEAT_TIMEOUT = 10000;
wss.on("connection", (ws) => {
let isAlive = true;
ws.on("pong", () => {
isAlive = true;
});
const heartbeat = setInterval(() => {
if (!isAlive) {
ws.terminate();
clearInterval(heartbeat);
return;
}
isAlive = false;
ws.ping();
}, HEARTBEAT_INTERVAL);
ws.on("close", () => clearInterval(heartbeat));
});Without heartbeats, dead connections accumulate and waste server memory. A connection that silently dies never triggers the close event.
Key Takeaways
- Use WebSockets for bidirectional real-time communication — SSE is simpler for server-push-only patterns
- Authenticate during the handshake — reject unauthenticated connections immediately
- Define a typed message protocol — namespace types with colons for organization
- Implement exponential backoff reconnection on the client to avoid thundering herds
- Heartbeat pings detect dead connections — without them, stale connections leak memory


