Skip to main content
Node.js · lesson 14 of 20
Node.js intermediate Lesson 14 of 20

A WebSocket Server with ws

Attach ws to a node:http server, broadcast to every client, reject a bad upgrade token with a 401, and terminate an unresponsive client with ping/pong.

By SkillByExample · Updated

nodejs websocket ws realtime

Node 26 ships a built-in WebSocket client — stable since Node 22.4, the same class a browser provides — but nothing that accepts a WebSocket connection. ws fills exactly that gap: it turns an already-running node:http server’s upgrade requests into full-duplex connections.

Attaching ws to a Plain HTTP Server

WebSocketServer({ noServer: true }) creates a ws server with no socket of its own — it only hands you handleUpgrade, which you call yourself once you’ve decided a given request should become a WebSocket:

// server-echo.js
import { createServer } from "node:http";
import { WebSocketServer } from "ws";

const server = createServer((req, res) => {
  res.writeHead(200, { "content-type": "text/plain" }).end("http ok\n");
});

const wss = new WebSocketServer({ noServer: true });

wss.on("connection", (ws) => {
  console.log("client connected");
  ws.on("message", (data) => {
    ws.send(`echo: ${data}`);
  });
  ws.on("close", () => console.log("client disconnected"));
});

server.on("upgrade", (req, socket, head) => {
  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit("connection", ws, req);
  });
});

server.listen(3000, () => console.log("listening on :3000"));

package.json pins the one dependency this entire lesson uses, with no ^ range so a reader installs exactly what produced the output below:

{
  "name": "websockets-with-ws",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "ws": "8.22.1"
  }
}

node_modules lives in its own named Docker volume, nodejs-websockets-with-ws-modules, for the same reason every other lesson in this topic uses one — a bind mount shares files with the Linux VM too slowly for node_modules’ thousands of small files:

$ docker run --rm -v "$(pwd)":/app -v nodejs-websockets-with-ws-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 1 package in 1s

The client is Node’s own global WebSocket, the same class Browser Web APIs in JavaScript (its WebSockets section) already covers in depth for reconnect-with-backoff — no import needed, and nothing server-shaped to re-teach here:

// client-echo.js
const ws = new WebSocket("ws://localhost:3000");

ws.addEventListener("open", () => {
  ws.send("hello");
});

ws.addEventListener("message", (e) => {
  console.log("received:", e.data);
  ws.close();
});

The client script runs with docker exec, inside the same container the server is already running in, so ws://localhost:3000 reaches it directly with no extra network to set up:

$ docker run -d --name nodejs-ws-echo -p 3000:3000 -v "$(pwd)":/app -v nodejs-websockets-with-ws-modules:/app/node_modules -w /app node:26.13 node server-echo.js
358abb6c8f3d4b1b362e8a72b551b24509a2f5b0b861a5bf814ac635a236ac26
$ until docker logs nodejs-ws-echo 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s http://localhost:3000/
http ok
$ docker exec nodejs-ws-echo node client-echo.js
received: echo: hello
$ docker logs nodejs-ws-echo
listening on :3000
client connected
client disconnected
$ docker rm -f nodejs-ws-echo
nodejs-ws-echo

The plain curl request hits the same createServer callback every node:http server has, and gets http ok back — this server never stopped being an ordinary HTTP server. The WebSocket request is a completely different code path: it never reaches that callback at all, because Node routes it to the upgrade event instead, which is where handleUpgrade hands the connection to ws.

Broadcasting to Every Connected Client

A chat room isn’t request/response — one client’s message has to reach every other connected client, not just come back to the sender. wss.clients is the live Set of every open connection this server currently has:

// server-broadcast.js
import { createServer } from "node:http";
import { WebSocketServer } from "ws";

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

wss.on("connection", (ws) => {
  console.log("client joined, total", wss.clients.size);
  ws.on("message", (data) => {
    for (const client of wss.clients) {
      client.send(`broadcast: ${data}`);
    }
  });
  ws.on("close", () => console.log("client left, total", wss.clients.size));
});

server.on("upgrade", (req, socket, head) => {
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});

server.listen(3000, () => console.log("listening on :3000"));

Two connections in the same client process, so their arrival order is under this script’s own control instead of being a race between two separate processes. The two console.log lines only run once both clients have a value, printed in a fixed A-then-B order — this reports what each client received, not whatever order the network happened to deliver the two broadcasts in:

// client-broadcast.js
const a = new WebSocket("ws://localhost:3000");
const b = new WebSocket("ws://localhost:3000");

let receivedA = null;
let receivedB = null;

function report() {
  if (receivedA !== null && receivedB !== null) {
    console.log("client A received:", receivedA);
    console.log("client B received:", receivedB);
    a.close();
    b.close();
  }
}

a.addEventListener("message", (e) => { receivedA = e.data; report(); });
b.addEventListener("message", (e) => { receivedB = e.data; report(); });

a.addEventListener("open", () => {
  b.addEventListener("open", () => {
    a.send("hello from A");
  });
});
$ docker run -d --name nodejs-ws-broadcast -p 3000:3000 -v "$(pwd)":/app -v nodejs-websockets-with-ws-modules:/app/node_modules -w /app node:26.13 node server-broadcast.js
56535372c617aa20026fc7c35e086567435c66c7fdd00f24e0bde9b644445fed
$ until docker logs nodejs-ws-broadcast 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec nodejs-ws-broadcast node client-broadcast.js
client A received: broadcast: hello from A
client B received: broadcast: hello from A
$ docker logs nodejs-ws-broadcast
listening on :3000
client joined, total 1
client joined, total 2
client left, total 1
client left, total 0
$ docker rm -f nodejs-ws-broadcast
nodejs-ws-broadcast

Both clients receive broadcast: hello from A, including the one that sent it — this server never checks client !== ws before sending, so the sender is part of its own audience. total on each close already reflects the client that just left: ws’s own bookkeeping removes a socket from wss.clients before it emits close, which is why the count goes straight from 2 to 1 to 0 rather than ever showing a leftover entry.

Checking a Token at Upgrade Time

The upgrade event fires for any request that carries a Connection: Upgrade header, whatever protocol its Upgrade header names — a WebSocket handshake just happens to be the only one this lesson, or most servers, ever see in practice. The listener gets the raw (req, socket, head), not a response object, which is exactly the right place to check a credential before ws ever takes the socket:

// server-auth.js
import { createServer } from "node:http";
import { WebSocketServer } from "ws";

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

wss.on("connection", (ws) => {
  console.log("client connected");
  ws.on("message", (data) => ws.send(`echo: ${data}`));
  ws.on("close", () => console.log("client disconnected"));
});

server.on("upgrade", (req, socket, head) => {
  const token = new URL(req.url, "http://localhost").searchParams.get("token");
  if (token !== "secret123") {
    console.log("upgrade rejected: bad token");
    socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
    socket.destroy();
    return;
  }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});

server.listen(3000, () => console.log("listening on :3000"));

curl -i can send the same handshake headers a real WebSocket client would, which is a cleaner way to inspect a rejected upgrade than Node’s own client — its ErrorEvent for a failed handshake carries an empty message and a close code of 1006, neither of which says why it failed. A raw curl request shows the real status line:

$ docker run -d --name nodejs-ws-auth -p 3000:3000 -v "$(pwd)":/app -v nodejs-websockets-with-ws-modules:/app/node_modules -w /app node:26.13 node server-auth.js
c8b8e6035a197a3d576ad2d77e66b63efbba5b5636bf5ffa36fc74a45aa884ae
$ until docker logs nodejs-ws-auth 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s -i -N "http://localhost:3000/ws?token=wrong" -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=="
HTTP/1.1 401 Unauthorized
Connection: close
$ curl -s -i -N "http://localhost:3000/ws?token=secret123" -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" --max-time 1
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
$ docker logs nodejs-ws-auth
listening on :3000
upgrade rejected: bad token
client connected
client disconnected
$ docker rm -f nodejs-ws-auth
nodejs-ws-auth

The bad token gets a clean HTTP/1.1 401 Unauthorized, one line in the server’s own log, and no ws connection ever created. The good token gets HTTP/1.1 101 Switching Protocols with a Sec-WebSocket-Accept value — the standard proof the server actually computed a response to this exact Sec-WebSocket-Key. Both the key, dGhlIHNhbXBsZSBub25jZQ==, and the accept value ws computes from it, s3pPLMBiTxaQ9kYGzzhZRbK+xOo=, are fixed in this request because dGhlIHNhbXBsZSBub25jZQ== is RFC 6455’s own worked example (§1.3): a real client generates a fresh random key per connection, but reusing the spec’s sample value here is what makes this response reproducible to show. The --max-time 1 on that second request is only there so curl doesn’t sit on the now-open connection forever; the server’s own log shows the connection closing once curl drops it. But notice what the check doesn’t do: ?token=secret123 is sitting in that URL, which means it’s also sitting in every access log and proxy that request passed through, and once the handshake succeeds, nothing checks the token again — every message this same connection ever sends afterward is trusted on the strength of that one query string.

A Heartbeat That Terminates an Unresponsive Client

TCP alone won’t tell either side that the other process has hung or lost its network — ws’s own heartbeat recipe is to ping every client on an interval, and terminate any client whose last ping never got a pong back:

// server-heartbeat.js
import { createServer } from "node:http";
import { WebSocketServer } from "ws";

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

function markAlive() {
  this.isAlive = true;
}

wss.on("connection", (ws) => {
  ws.isAlive = true;
  ws.on("pong", markAlive);
  console.log("client connected");
  ws.on("close", () => console.log("client closed"));
});

setInterval(() => {
  for (const ws of wss.clients) {
    if (ws.isAlive === false) {
      console.log("terminating unresponsive client");
      ws.terminate();
      continue;
    }
    ws.isAlive = false;
    ws.ping();
  }
}, 500);

server.on("upgrade", (req, socket, head) => {
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});

server.listen(3000, () => console.log("listening on :3000"));

A WebSocket client answers a ping with a pong automatically, at the protocol level — but “automatically” still means some JavaScript has to run on that client’s one thread to send it. A “good” client that just stays open does nothing special:

// client-good.js
const ws = new WebSocket("ws://localhost:3000");

ws.addEventListener("open", () => {
  console.log("good client open");
  setTimeout(() => {
    console.log("good client closing itself");
    ws.close();
  }, 1300);
});

ws.addEventListener("close", (e) => {
  console.log("good client closed, code", e.code);
});

A “bad” client that blocks its own event loop right after connecting can’t run that automatic reply either — the same single-thread rule What Node.js Is demonstrated with a busy loop delaying an already-due timer applies just as much to a queued-up pong:

// client-bad.js
const ws = new WebSocket("ws://localhost:3000");

ws.addEventListener("open", () => {
  console.log("bad client open, now blocking its own event loop");
  const start = Date.now();
  while (Date.now() - start < 1600) {
    // block this client's one JS thread so it can never answer a ping with a pong
  }
  console.log("bad client unblocked");
});

ws.addEventListener("close", (e) => {
  console.log("bad client closed, code", e.code);
});

The good client runs first, to confirm the heartbeat alone never closes a client that’s actually answering; then the bad one runs against a fresh connection on the same still-running server, and the counts before and after make the claim visible instead of just asserting it in prose:

$ docker run -d --name nodejs-ws-heartbeat -p 3000:3000 -v "$(pwd)":/app -v nodejs-websockets-with-ws-modules:/app/node_modules -w /app node:26.13 node server-heartbeat.js
feb55ac175a9d00cb5d766a62dce4210f576ce4c548aca34085a4186c0152407
$ until docker logs nodejs-ws-heartbeat 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec nodejs-ws-heartbeat node client-good.js
good client open
good client closing itself
good client closed, code 1005
$ term_after_good=$(docker logs nodejs-ws-heartbeat 2>&1 | grep -c terminating); echo "terminations after the good client: $term_after_good"
terminations after the good client: 0
$ docker exec nodejs-ws-heartbeat node client-bad.js
bad client open, now blocking its own event loop
bad client unblocked
bad client closed, code 1006
$ term_after_bad=$(docker logs nodejs-ws-heartbeat 2>&1 | grep -c terminating); echo "terminations after the bad client: $term_after_bad"
terminations after the bad client: 1
$ [ "$term_after_good" = "0" ] && [ "$term_after_bad" = "1" ] && echo ONLY_THE_BAD_CLIENT_WAS_TERMINATED
ONLY_THE_BAD_CLIENT_WAS_TERMINATED
$ docker logs nodejs-ws-heartbeat
listening on :3000
client connected
client closed
client connected
terminating unresponsive client
client closed
$ docker rm -f nodejs-ws-heartbeat
nodejs-ws-heartbeat

terminations after the good client: 0, then terminations after the bad client: 1 — ONLY_THE_BAD_CLIENT_WAS_TERMINATED confirms it’s a count, not a guess. The good client’s own ws.close() reports code 1005, not a normal closure — 1000 is the code for that. ws.close() called with no arguments sends a close frame with no status code at all, so the other side has nothing to report back except 1005, WebSocket’s own reserved value for “no status code was present.” The bad client, after blocking its own event loop for 1600 ms against a 500 ms ping interval, misses a pong and comes back with 1006 — the code ws.terminate() produces, because the server tore the connection down with no close handshake at all. The full server log shows exactly one terminating unresponsive client line, for the second connection only.

Practice

1. Extend client-broadcast.js to open a third connection, c, and confirm server-broadcast.js — unmodified — delivers the broadcast to all three.

Solution
// client-broadcast.js (three clients)
const a = new WebSocket("ws://localhost:3000");
const b = new WebSocket("ws://localhost:3000");
const c = new WebSocket("ws://localhost:3000");

let receivedA = null;
let receivedB = null;
let receivedC = null;

function report() {
  if (receivedA !== null && receivedB !== null && receivedC !== null) {
    console.log("client A received:", receivedA);
    console.log("client B received:", receivedB);
    console.log("client C received:", receivedC);
    a.close();
    b.close();
    c.close();
  }
}

a.addEventListener("message", (e) => { receivedA = e.data; report(); });
b.addEventListener("message", (e) => { receivedB = e.data; report(); });
c.addEventListener("message", (e) => { receivedC = e.data; report(); });

a.addEventListener("open", () => {
  b.addEventListener("open", () => {
    c.addEventListener("open", () => {
      a.send("hello from A");
    });
  });
});
$ docker run -d --name nodejs-ws-broadcast3 -p 3000:3000 -v "$(pwd)":/app -v nodejs-websockets-with-ws-modules:/app/node_modules -w /app node:26.13 node server-broadcast.js
40df780111eedb88de82e83123c1f0abf59fcd1c27ec53cdde137717900871d4
$ until docker logs nodejs-ws-broadcast3 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec nodejs-ws-broadcast3 node client-broadcast.js
client A received: broadcast: hello from A
client B received: broadcast: hello from A
client C received: broadcast: hello from A
$ docker logs nodejs-ws-broadcast3
listening on :3000
client joined, total 1
client joined, total 2
client joined, total 3
client left, total 2
client left, total 1
client left, total 0
$ docker rm -f nodejs-ws-broadcast3
nodejs-ws-broadcast3

server-broadcast.js never assumed exactly two clients — its for (const client of wss.clients) loop already covers however many are connected, so the third connection needed no server change at all, only a third branch in the client script’s own join/report bookkeeping.

2. Move server-auth.js’s token check from the query string to a request header, x-auth-token, and confirm curl -i still gets 401/101 for a bad/good value.

Solution
// server-auth.js (token read from a header instead of the query string)
import { createServer } from "node:http";
import { WebSocketServer } from "ws";

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

wss.on("connection", (ws) => {
  console.log("client connected");
  ws.on("message", (data) => ws.send(`echo: ${data}`));
  ws.on("close", () => console.log("client disconnected"));
});

server.on("upgrade", (req, socket, head) => {
  const token = req.headers["x-auth-token"]; // was: new URL(req.url, ...).searchParams.get("token")
  if (token !== "secret123") {
    console.log("upgrade rejected: bad token");
    socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
    socket.destroy();
    return;
  }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});

server.listen(3000, () => console.log("listening on :3000"));
$ docker run -d --name nodejs-ws-auth-header -p 3000:3000 -v "$(pwd)":/app -v nodejs-websockets-with-ws-modules:/app/node_modules -w /app node:26.13 node server-auth.js
fc437878d8b4d5fe6488d28653bc63c8877d9f18ca54effc2f6b02bf060bffc3
$ until docker logs nodejs-ws-auth-header 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s -i -N "http://localhost:3000/ws" -H "x-auth-token: wrong" -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=="
HTTP/1.1 401 Unauthorized
Connection: close
$ curl -s -i -N "http://localhost:3000/ws" -H "x-auth-token: secret123" -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" --max-time 1
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
$ docker rm -f nodejs-ws-auth-header
nodejs-ws-auth-header

Same two outcomes as the query-string version — 401 then 101 — because req.headers is just as available on the raw req the upgrade event hands you as req.url was; only where the token travels changed, not how it’s checked. A header at least keeps the token out of URL-based access logs, though it’s still sent in the clear over a plain ws:// connection and still checked only once.

3. Change server-heartbeat.js to require four consecutive missed pongs (not one) before terminating a client, and confirm a 1400 ms block from client-bad.js now survives — then confirm a longer, 3000 ms block still gets terminated.

Solution
// server-heartbeat-grace.js (terminate only after 4 missed ping intervals, not 1)
import { createServer } from "node:http";
import { WebSocketServer } from "ws";

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

function markAlive() {
  this.missedPongs = 0;
}

wss.on("connection", (ws) => {
  ws.missedPongs = 0;
  ws.on("pong", markAlive);
  console.log("client connected");
  ws.on("close", () => console.log("client closed"));
});

setInterval(() => {
  for (const ws of wss.clients) {
    if (ws.missedPongs >= 4) {
      console.log("terminating unresponsive client");
      ws.terminate();
      continue;
    }
    ws.missedPongs += 1;
    ws.ping();
  }
}, 500);

server.on("upgrade", (req, socket, head) => {
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});

server.listen(3000, () => console.log("listening on :3000"));

client-bad.js needs one change: a client that survives now stays open past its own blocking loop, so it needs to close itself afterward instead of waiting for a termination that may never come:

// client-bad.js (closes itself if it survives)
const ws = new WebSocket("ws://localhost:3000");

ws.addEventListener("open", () => {
  console.log("bad client open, now blocking its own event loop");
  const start = Date.now();
  while (Date.now() - start < 1400) {
    // block this client's one JS thread so it can never answer a ping with a pong
  }
  console.log("bad client unblocked");
});

ws.addEventListener("close", (e) => {
  console.log("bad client closed, code", e.code);
});

setTimeout(() => {
  console.log("bad client still open, closing it itself");
  ws.close();
}, 4000);
$ docker run -d --name nodejs-ws-heartbeat-grace -p 3000:3000 -v "$(pwd)":/app -v nodejs-websockets-with-ws-modules:/app/node_modules -w /app node:26.13 node server-heartbeat-grace.js
7b213607a4973558e6c6b23a4eae7824382029a452bac2c800d2c19583dbecdd
$ until docker logs nodejs-ws-heartbeat-grace 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec nodejs-ws-heartbeat-grace node client-bad.js
bad client open, now blocking its own event loop
bad client unblocked
bad client still open, closing it itself
bad client closed, code 1005
$ docker logs nodejs-ws-heartbeat-grace 2>&1 | grep -c terminating
0
$ docker rm -f nodejs-ws-heartbeat-grace
nodejs-ws-heartbeat-grace

0 terminations, and the client’s own closing it itself line fires instead — the 1600 ms block that got a client terminated against server-heartbeat.js’s one-miss rule would only clear this grace period’s earliest termination point, four missed 500 ms intervals (2000 ms), by about 400 ms, so this version blocks for 1400 ms instead, a real 600 ms of margin. Unblocking at 1400 ms leaves plenty of that window for a pong to get through. A longer block closes that window:

// client-bad-longer.js
const ws = new WebSocket("ws://localhost:3000");

ws.addEventListener("open", () => {
  console.log("bad client open, now blocking its own event loop for longer");
  const start = Date.now();
  while (Date.now() - start < 3000) {
    // block well past the grace period's four missed-pong allowance, with margin for phase alignment
  }
  console.log("bad client unblocked");
});

ws.addEventListener("close", (e) => {
  console.log("bad client closed, code", e.code);
});
$ docker run -d --name nodejs-ws-heartbeat-grace2 -p 3000:3000 -v "$(pwd)":/app -v nodejs-websockets-with-ws-modules:/app/node_modules -w /app node:26.13 node server-heartbeat-grace.js
eb7da02cfb80687581c617d636b9cda9f78db05e0e7c7e49305ff28eb71c4d9c
$ until docker logs nodejs-ws-heartbeat-grace2 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec nodejs-ws-heartbeat-grace2 node client-bad-longer.js
bad client open, now blocking its own event loop for longer
bad client unblocked
bad client closed, code 1006
$ docker logs nodejs-ws-heartbeat-grace2 2>&1 | grep -c terminating
1
$ docker rm -f nodejs-ws-heartbeat-grace2
nodejs-ws-heartbeat-grace2

1 termination this time, and code 1006 instead of 1005 — 3000 ms of blocking outlasts even the four-miss grace period’s 2000 ms window plus the up-to-500 ms of phase alignment between when that interval started ticking and when this client happened to connect, so the connection is gone (ws.terminate(), abnormal closure) before this client ever gets a chance to unblock and close itself.

4. Change server-broadcast.js so a message only reaches other clients, never back to whoever sent it, and confirm with two clients that only the one that didn’t send gets a message.

Solution
// server-broadcast.js (excludes the sender)
import { createServer } from "node:http";
import { WebSocketServer } from "ws";

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

wss.on("connection", (ws) => {
  console.log("client joined, total", wss.clients.size);
  ws.on("message", (data) => {
    for (const client of wss.clients) {
      if (client !== ws) client.send(`broadcast: ${data}`); // new: skip the sender
    }
  });
  ws.on("close", () => console.log("client left, total", wss.clients.size));
});

server.on("upgrade", (req, socket, head) => {
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});

server.listen(3000, () => console.log("listening on :3000"));
// client-broadcast-exclude.js
const a = new WebSocket("ws://localhost:3000");
const b = new WebSocket("ws://localhost:3000");

let gotA = false;
let gotB = false;

a.addEventListener("message", (e) => { gotA = true; console.log("client A received:", e.data); });
b.addEventListener("message", (e) => { gotB = true; console.log("client B received:", e.data); });

a.addEventListener("open", () => {
  b.addEventListener("open", () => {
    a.send("hello from A");
    setTimeout(() => {
      console.log("client A received a message:", gotA);
      console.log("client B received a message:", gotB);
      a.close();
      b.close();
    }, 300);
  });
});
$ docker run -d --name nodejs-ws-broadcast-exclude -p 3000:3000 -v "$(pwd)":/app -v nodejs-websockets-with-ws-modules:/app/node_modules -w /app node:26.13 node server-broadcast.js
3bc617b381dc2244cdfb641d023dd1580ca96c57283087b396d04ee2c641e1d1
$ until docker logs nodejs-ws-broadcast-exclude 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec nodejs-ws-broadcast-exclude node client-broadcast-exclude.js
client B received: broadcast: hello from A
client A received a message: false
client B received a message: true
$ docker rm -f nodejs-ws-broadcast-exclude
nodejs-ws-broadcast-exclude

Only client B received: ever prints — the sender, a, never gets its own message echoed back, and the two boolean checks at the end confirm it directly: false for A, true for B. The one-line change to the broadcast loop’s condition is the only difference from the main example; everything else about how a client joins, sends, and leaves stayed the same.

Frequently Asked Questions

Why does this lesson attach ws to a node:http server with noServer: true instead of letting WebSocketServer listen on its own port?
noServer: true gives ws no socket of its own — it only turns an already-accepted connection into a WebSocket once your own code calls handleUpgrade. That one node:http server keeps answering ordinary requests (a plain curl GET to / still gets http ok back) and its upgrade event is where this lesson's token check runs, before ws ever takes over the socket. A standalone WebSocketServer({ port }) does have a hook for this, verifyClient, but ws's own docs say its use is discouraged and recommend handling authentication in the HTTP server's own upgrade event instead — exactly the noServer plus handleUpgrade pattern this lesson uses.
Why does the bad-token response use socket.write and socket.destroy instead of a normal res.writeHead?
The upgrade event on a node:http server hands your listener (req, socket, head) — there is no res object, because the server hasn't decided yet whether this request becomes a WebSocket or a plain HTTP response. Writing the raw HTTP response text onto the socket directly, then destroying it, is how you answer with an ordinary status code (401 Unauthorized here) without ever calling handleUpgrade.
Does checking a token at upgrade time make a WebSocket connection fully secure?
No. A token passed in the URL's query string, as this lesson's server-auth.js does, ends up in server access logs and any proxy in front of it — a bigger leak surface than a header. And the check only runs once, at the handshake: every message sent afterward on that same connection is trusted with no further per-message authorization, so a connection that was valid when it opened is still fully trusted an hour later with the same token.
Why does the unresponsive client in the heartbeat example close with code 1006 instead of a normal close?
1006 is WebSocket's reserved code for an abnormal closure — no close frame ever went back and forth. server-heartbeat.js calls ws.terminate(), which destroys the underlying TCP socket immediately instead of running the close handshake ws.close() would, which is exactly why ws.terminate() is the right call for a peer that has already stopped answering: the normal handshake would just become something else to wait for.
What Node.js and ws versions does this lesson use?
Node.js 26.13.0 (Docker node:26.13) and ws 8.22.1. Node 26 still has no built-in WebSocket server — only the global WebSocket client, stable since Node 22.4 — which is what every client script in this lesson uses with no import at all.