ws Library
Build production WebSocket servers in Node.js with the ws library, including heartbeat, auth, and graceful shutdown.
Recipe
Quick-reference recipe card - copy-paste ready.
import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "node:http";
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});
wss.on("connection", (ws) => {
ws.on("message", (data) => ws.send(data));
ws.on("close", () => console.log("Client disconnected"));
});
server.listen(3000);When to reach for this: You need raw WebSocket control without Socket.IO overhead, or NestJS/Fastify WebSocket gateway.
Working Example
import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "node:http";
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
function heartbeat() {
// @ts-expect-error custom property
this.isAlive = true;
}
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url ?? "/", "http://localhost");
const token = url.searchParams.get("token");
if (token !== process.env.WS_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));
});
wss.on("connection", (ws: WebSocket) => {
ws.isAlive = true;
ws.on("pong", heartbeat);
ws.on("message", (raw) => {
const msg = JSON.parse(String(raw));
if (msg.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
return;
}
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: "broadcast", data: msg }));
}
});
});
ws.on("close", () => console.log("Client left"));
});
// Heartbeat every 30s
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) { ws.terminate(); return; }
ws.isAlive = false;
ws.ping();
});
}, 30_000);
wss.on("close", () => clearInterval(interval));
server.listen(3000);What this demonstrates:
- Token auth during HTTP upgrade
- Ping/pong heartbeat to detect dead connections
- JSON message protocol with type field
- Broadcast to all connected clients
terminate()for unresponsive clients
Deep Dive
How It Works
wsimplements the WebSocket protocol (RFC 6455)noServer: truelets you handle upgrade manually (for auth)ping()/pong()are protocol-level frames (not application messages)wss.clientsis a Set of all connected WebSocket instances
Client Connection (Browser)
const ws = new WebSocket("ws://localhost:3000?token=secret");
ws.onopen = () => ws.send(JSON.stringify({ type: "hello" }));
ws.onmessage = (e) => console.log(JSON.parse(e.data));
ws.onclose = () => console.log("Disconnected");NestJS Integration
@WebSocketGateway({ path: "/ws" })
export class EventsGateway {
@SubscribeMessage("message")
handleMessage(@MessageBody() data: string) {
return { event: "response", data };
}
}Gotchas
- No auth on upgrade - anyone can connect and send messages. Fix: validate token before
handleUpgrade. - No heartbeat - dead connections stay in
wss.clientsforever. Fix: ping/pong interval withterminate(). - Broadcasting to all clients - CPU spikes at scale. Fix: room-based fanout or Redis pub/sub.
- Not handling
closeon server shutdown - clients get RST. Fix: notify clients, thenwss.close(). - Parsing unvalidated JSON - crash on malformed messages. Fix: try/catch with error response.
- Memory leak from event listeners - listeners accumulate on reconnect. Fix: remove listeners on
close.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Socket.IO | Need rooms, fallback, Redis adapter | Want minimal protocol overhead |
| SSE | Server-to-client only | Bidirectional messaging |
| uWebSockets.js | Maximum WebSocket throughput | Simpler ws API preferred |
| NestJS Gateway | NestJS app with decorators | Non-NestJS project |
FAQs
ws vs Socket.IO?
ws is raw WebSocket. Socket.IO adds rooms, namespaces, fallback, and Redis adapter. Use ws when you want control; Socket.IO when you want features.
How do I limit connections per IP?
Track connections in a Map keyed by IP during upgrade. Reject when limit exceeded.
Does ws work with Fastify?
Yes via @fastify/websocket plugin, which wraps ws internally.
How do I send binary data?
ws.send(buffer) with Buffer or ArrayBuffer. Useful for file chunks or protobuf.
What about WSS (secure WebSocket)?
Terminate TLS at the proxy (same as HTTPS). Client connects to wss:// which proxies to ws:// on Node.
How many clients can ws handle?
10k-50k idle per process. Depends on message rate and fanout pattern. Profile your workload.
How do I test WebSocket servers?
Use ws WebSocket client in tests, or websocket npm package. Connect to server.address() port.
Should I use JSON or binary protocol?
JSON for simplicity. Binary (protobuf, msgpack) when message volume or size demands it.
Related
- Real-Time Basics - protocol selection
- Socket.IO - higher-level alternative
- Scaling Real-Time - multi-instance
- HTTP Basics in Node - HTTP upgrade
- Real-Time Best Practices - section checklist
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.