Node.js · lesson 14 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
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
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.