Skip to content

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.

3 min read
WebSocket connection lifecycle diagram showing handshake, message exchange, and close

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.

PatternHTTP worksWebSockets better
Dashboard refreshing every 30s✅ Polling is fineOverkill
Live chat messagesPolling wastes bandwidth✅ Instant delivery
Collaborative document editingNot 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

tstypescript
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.

tstypescript
// ❌ 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.

tstypescript
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.

tstypescript
// ✅ 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.

tstypescript
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

  1. Use WebSockets for bidirectional real-time communication — SSE is simpler for server-push-only patterns
  2. Authenticate during the handshake — reject unauthenticated connections immediately
  3. Define a typed message protocol — namespace types with colons for organization
  4. Implement exponential backoff reconnection on the client to avoid thundering herds
  5. Heartbeat pings detect dead connections — without them, stale connections leak memory
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX