Skip to content
C Codeloom
Backend

WebSockets in Node.js: Real-Time From Scratch

Build a real WebSocket server in Node.js: handshake, broadcasting, heartbeats, backpressure, and scaling beyond a single process.

·4 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • Stand up a WebSocket server with ws
  • Implement rooms and broadcast cleanly
  • Use heartbeats to detect dead connections
  • Handle backpressure without OOMing
  • Scale across processes with Redis pub/sub

Prerequisites

  • Comfort with Node.js: [What is Node.js](/blog/what-is-nodejs)
  • HTTP basics: [What is REST](/blog/what-is-rest)
  • Optional: a Redis instance

HTTP is request and response. WebSockets are a persistent, full-duplex channel after a one-time upgrade. The protocol is simple; making it survive flaky networks, slow clients, and multiple processes is where the work lives.

The handshake

The client sends an HTTP request with Upgrade: websocket. The server accepts and the same TCP socket flips into the WebSocket frame protocol. After that, both sides send messages whenever they like.

You almost never write that by hand. Use the ws library.

A minimal server

npm install ws
import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (socket, req) => {
  console.log("connected from", req.socket.remoteAddress);
  socket.send(JSON.stringify({ type: "hello" }));

  socket.on("message", (data) => {
    const msg = JSON.parse(data.toString());
    socket.send(JSON.stringify({ type: "echo", payload: msg }));
  });

  socket.on("close", (code) => console.log("closed", code));
});

Connect from the browser:

const ws = new WebSocket("ws://localhost:8080");
ws.onopen = () => ws.send(JSON.stringify({ hi: "world" }));
ws.onmessage = (e) => console.log(e.data);

That is the entire mental model. Now the production parts.

Authenticate at upgrade time

Do not accept any connection. Verify a token during the upgrade so you can reject before allocating a socket.

import http from "node:http";

const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });

server.on("upgrade", (req, socket, head) => {
  const token = new URL(req.url!, "http://x").searchParams.get("token");
  if (!isValid(token)) {
    socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
    socket.destroy();
    return;
  }
  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit("connection", ws, req, decode(token));
  });
});

server.listen(8080);

Cookies work too. Pick whichever your auth stack already uses; see JWT Authentication Explained.

Rooms and broadcast

Group connections by room. Iterate the group, not every socket, on each broadcast.

const rooms = new Map<string, Set<WebSocket>>();

function join(room: string, ws: WebSocket) {
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room)!.add(ws);
  ws.on("close", () => rooms.get(room)?.delete(ws));
}

function broadcast(room: string, payload: unknown) {
  const data = JSON.stringify(payload);
  for (const ws of rooms.get(room) ?? []) {
    if (ws.readyState === ws.OPEN) ws.send(data);
  }
}

For thousands of rooms, this still scales linearly. Avoid per-message JSON if hot paths show up in flame graphs.

Heartbeats

TCP can keep a half-dead connection open for hours. Send pings and prune sockets that miss a pong.

wss.on("connection", (ws) => {
  (ws as any).isAlive = true;
  ws.on("pong", () => ((ws as any).isAlive = true));
});

setInterval(() => {
  for (const ws of wss.clients) {
    if (!(ws as any).isAlive) return ws.terminate();
    (ws as any).isAlive = false;
    ws.ping();
  }
}, 30_000);

Thirty seconds is a reasonable default. Tune per network conditions.

Backpressure

If a client is slow and your server keeps sending, internal buffers grow. Check bufferedAmount and drop or queue.

function safeSend(ws: WebSocket, data: string, max = 1_000_000) {
  if (ws.bufferedAmount > max) return false;
  ws.send(data);
  return true;
}

For high fanout, batch messages, compress with permessage-deflate, or move to a binary format like CBOR.

Scaling beyond one process

A WebSocket lives on one process. Two replicas cannot broadcast to each other without a backbone. Use Redis pub/sub.

import Redis from "ioredis";
const pub = new Redis(), sub = new Redis();

sub.subscribe("room:lobby");
sub.on("message", (_ch, msg) => broadcast("lobby", JSON.parse(msg)));

function publish(room: string, payload: unknown) {
  pub.publish(`room:${room}`, JSON.stringify(payload));
}

Every replica subscribes to the rooms it owns and republishes to local sockets. Sticky sessions or a stateful router keep a user pinned to one process.

Reconnect strategy

Networks drop. The client must reconnect with exponential backoff and resubscribe.

let delay = 500;
function connect() {
  const ws = new WebSocket(URL);
  ws.onopen = () => { delay = 500; };
  ws.onclose = () => setTimeout(connect, delay = Math.min(delay * 2, 30_000));
}

On the server, accept a last-event-id style cursor and replay missed messages from a short ring buffer.

When not to use WebSockets

For server-to-client updates only, Server-Sent Events are simpler and survive proxies better. For request and response, stick with HTTP. WebSockets are for bidirectional, long-lived, low-latency channels: chat, presence, collaborative editing, live trading.

Wrap up

A working WebSocket server is twenty lines. A production one adds auth at upgrade, heartbeats, backpressure, a pub/sub backbone, and a reconnect contract with the client. Get those five right and the channel becomes invisible infrastructure.