WebSockets in Node.js
A WebSocket is a persistent, full-duplex connection between a client and a server that starts life as an ordinary HTTP request and then upgrades to its own protocol. Once the upgrade completes, either side can push data to the other at any moment, without the client having to ask first — no polling, no re-establishing a connection for every message. This makes WebSockets the standard tool for chat apps, live dashboards, multiplayer games, collaborative editors, and anything that needs the server to notify clients the instant something changes. Node.js has no built-in WebSocket server, but its event-driven, non-blocking I/O model — the same event loop that powers http and fs — makes it an excellent fit for holding open thousands of concurrent WebSocket connections cheaply.
Overview: How WebSockets Work in Node.js
Every WebSocket connection begins as a normal HTTP GET request. The client sends an Upgrade: websocket header, a Connection: Upgrade header, and a randomly generated Sec-WebSocket-Key. If the server agrees to upgrade, it replies with HTTP/1.1 101 Switching Protocols and a Sec-WebSocket-Accept header computed by hashing that key with a fixed magic string (SHA-1, then base64). After that single exchange, the underlying TCP socket stops being used for HTTP entirely — both sides now exchange small binary WebSocket frames on the same connection, which stays open until either side closes it.
Node’s core http module knows just enough about this to get out of the way: when a request carries an Upgrade header, the server emits an 'upgrade' event instead of the usual 'request' event, handing you the raw socket. Node does not ship a full WebSocket implementation in its standard library, so in practice you install the ws package — the de facto standard, and the library many frameworks use internally — to handle the handshake, frame parsing, and control messages for you. (Node 20+ does expose a global, browser-compatible WebSocket client for making outgoing connections, but there is still no built-in server.)
Because everything runs on Node’s single-threaded event loop, incoming frames are read the same way any other socket data is read: libuv polls the file descriptor during the poll phase, and when bytes arrive your 'message' handler is scheduled and run on a later turn of the loop. This means a WebSocket server scales the way an HTTP server does — thousands of idle connections cost very little, because nothing runs while there is no data — but it also means one slow, synchronous handler blocks every connected client at once, not just the one that triggered it. Frames also carry control opcodes for ping/pong (used for keepalive) and close (used for a graceful shutdown handshake with a status code and reason).
Syntax
Install the library first:
npm install ws
The core pieces of the ws API:
| Member | Kind | Description |
|---|---|---|
new WebSocketServer(options) |
constructor | Creates a server. Options include port (listen standalone), server (attach to an existing http.Server), and path. |
wss.on('connection', (socket, request) => {}) |
event | Fires once a client’s handshake succeeds; request is the original HTTP upgrade request. |
wss.clients |
property | A Set of every currently connected socket — useful for broadcasting. |
socket.send(data) |
method | Sends a string or Buffer to that client. |
socket.close(code, reason) |
method | Starts a graceful close handshake. |
socket.terminate() |
method | Immediately drops the TCP connection, no handshake. |
socket.ping() / 'pong' |
method/event | Application-level keepalive check. |
socket.readyState |
property | 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED. |
Examples
Example 1: A basic echo server
This server accepts connections and sends every message straight back to whoever sent it, prefixed with Echo:.
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (socket) => {
console.log("Client connected");
socket.on("message", (data) => {
console.log("Received:", data.toString());
socket.send(`Echo: ${data.toString()}`);
});
socket.on("close", () => {
console.log("Client disconnected");
});
socket.on("error", (err) => {
console.error("Socket error:", err.message);
});
});
console.log("WebSocket server listening on ws://localhost:8080");
Example 2: A client that talks to it
Run this in a second terminal while the server above is running.
import { WebSocket } from "ws";
const socket = new WebSocket("ws://localhost:8080");
socket.on("open", () => {
console.log("Connected to server");
socket.send("hello from client");
});
socket.on("message", (data) => {
console.log("Server says:", data.toString());
socket.close();
});
socket.on("error", (err) => {
console.error("Connection failed:", err.message);
});
Output:
Connected to server
Server says: Echo: hello from client
The client opens a connection, sends one string, and the server’s 'message' handler echoes it back with a prefix. Notice that both sides attach an 'error' listener — skip that and a failed connection can crash the process (see Common Mistakes below).
Example 3: A production-shaped chat server with heartbeat and graceful shutdown
Real deployments rarely run a bare WebSocket server. This example attaches ws to a normal http.Server (so the same port can also answer plain HTTP health checks), broadcasts messages to everyone else, detects dead connections with ping/pong, and shuts down cleanly on SIGTERM.
import { createServer } from "node:http";
import { WebSocketServer, WebSocket } from "ws";
const server = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("OK\n");
});
const wss = new WebSocketServer({ server });
function heartbeat() {
this.isAlive = true;
}
wss.on("connection", (socket) => {
socket.isAlive = true;
socket.on("pong", heartbeat);
socket.on("message", (data) => {
for (const client of wss.clients) {
if (client !== socket && client.readyState === WebSocket.OPEN) {
client.send(data.toString());
}
}
});
socket.on("error", (err) => {
console.error("Client error:", err.message);
});
});
const interval = setInterval(() => {
for (const socket of wss.clients) {
if (socket.isAlive === false) {
socket.terminate();
continue;
}
socket.isAlive = false;
socket.ping();
}
}, 30000);
wss.on("close", () => clearInterval(interval));
const port = process.env.PORT ?? 8080;
server.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
process.on("SIGTERM", () => {
console.log("Shutting down gracefully...");
clearInterval(interval);
for (const socket of wss.clients) {
socket.close(1001, "Server shutting down");
}
server.close(() => process.exit(0));
});
Output (on startup):
Server listening on port 8080
Every 30 seconds the interval marks each socket isAlive = false and sends a ping; a client that answers with pong flips it back to true via the shared heartbeat handler. Any socket still marked false on the next tick never answered, so it is forcibly terminated — this catches connections that died silently (a laptop that went to sleep, a NAT that dropped the mapping) without waiting for the OS to notice. On SIGTERM, the process stops the heartbeat timer, tells every client it is closing with status code 1001 (“going away”), and only then closes the HTTP server and exits — letting clients reconnect elsewhere instead of just vanishing.
How It Works Step by Step (Under the Hood)
- The client sends an HTTP request with
Upgrade: websocket,Connection: Upgrade, andSec-WebSocket-Key. - Node’s
httpserver sees theUpgradeheader and emits'upgrade'on the server object instead of routing it as a normal request;wslistens for this event internally when you pass it{ server }. wsvalidates the key, computesSec-WebSocket-Accept, and writes a raw101 Switching Protocolsresponse directly onto the socket.- From this point the TCP socket carries WebSocket frames, not HTTP. Each frame has an opcode (text, binary, ping, pong, or close), an optional mask, and a payload length.
- Incoming bytes are picked up by libuv during the event loop’s poll phase;
wsreassembles frames and emits a'message'event once a full message has arrived. - Closing is itself a small handshake: one side sends a close frame with a status code, the other echoes it back, and only then does the underlying TCP connection actually close.
Common Mistakes
1. Not handling the 'error' event
WebSocket instances are EventEmitters. If an 'error' event fires and nothing is listening for it, Node throws it as an uncaught exception, which can crash the entire process over a single failed connection.
const socket = new WebSocket("ws://localhost:8080");
socket.on("open", () => socket.send("hi"));
Corrected — always add an error listener:
const socket = new WebSocket("ws://localhost:8080");
socket.on("open", () => socket.send("hi"));
socket.on("error", (err) => console.error("WebSocket error:", err.message));
2. Blocking the event loop inside a message handler
A synchronous call inside a 'message' handler stalls the whole server, not just the client that sent the message, because every socket shares the same event loop.
socket.on("message", (data) => {
const report = fs.readFileSync("./large-report.csv", "utf8");
socket.send(report);
});
Corrected — use the promise-based, non-blocking API:
socket.on("message", async (data) => {
const report = await fs.promises.readFile("./large-report.csv", "utf8");
socket.send(report);
});
3. Sending to a socket without checking its state
A client can disconnect between when you looked it up and when you call send. Calling send on a closing or closed socket throws.
function broadcast(wss, message) {
wss.clients.forEach((client) => client.send(message));
}
Corrected — guard with readyState:
function broadcast(wss, message) {
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
4. Forgetting the proxy in production
Most Node apps sit behind a reverse proxy or load balancer (nginx, an ALB). If it isn’t explicitly configured to forward the Upgrade and Connection headers, the handshake never reaches your app and every connection fails with a generic error. This has nothing to do with your Node code, but it is the single most common reason a WebSocket server that works locally fails once deployed — check your proxy configuration first when that happens.
Best Practices
- Use
wsfor a lean, spec-compliant implementation, orsocket.ioif you want automatic reconnection, rooms, and fallback transports built in. - Always attach an
'error'listener to every socket and to the server itself. - Implement ping/pong heartbeats and terminate connections that stop responding — proxies and NATs silently drop idle TCP connections without telling either endpoint.
- Check
readyState === WebSocket.OPENbefore everysend. - Watch
socket.bufferedAmountbefore sending large or frequent payloads to a slow client to avoid unbounded memory growth (backpressure). - Handle
SIGTERM/SIGINTby closing client sockets with a real close code before exiting, so load balancers can drain connections cleanly during a deploy. - Serve over
wss://in production; terminate TLS at your proxy and make sure it forwards theUpgradeandConnectionheaders. - WebSocket connections are stateful and pinned to one server process — when scaling to multiple instances, use sticky sessions or a shared pub/sub layer (such as Redis) so a broadcast reaches clients connected to other instances.
- Authenticate connections (a token in the query string or the first message) and validate the
Originheader before trusting a client. - Limit message size and rate to protect the server from a single abusive client.
Practice Exercises
- Build a broadcast chat server where a message from one client reaches every other connected client, plus a plain HTTP
/healthroute on the same server that a load balancer could poll. - Add heartbeat ping/pong to a server of your own: mark each socket dead if it misses two consecutive pongs, and terminate it.
- Add graceful shutdown: on
SIGTERM, stop accepting new connections, close every open socket with status code1001, then exit only once the HTTP server itself has closed.
Summary
- A WebSocket starts as an HTTP request that upgrades to
101 Switching Protocols, then stays open for full-duplex, low-overhead messaging. - Node’s core
httpmodule only exposes the raw'upgrade'event; thewspackage handles the handshake and framing for you. - Incoming messages are delivered through the same event loop as everything else — a blocking handler stalls every connected client.
- Always handle the
'error'event, checkreadyStatebefore sending, and use ping/pong to detect dead connections. - Production deployments need a proxy that forwards upgrade headers, TLS via
wss://, a strategy for scaling across instances, and a graceful shutdown path.
