WebSockets in Node.js
HTTP is request-response: the client asks, the server answers, and the connection idles or closes. That model breaks down for chat, live dashboards, multiplayer games, and collaborative editors where the server needs to push data the instant something changes. WebSockets solve this by establishing a single, long-lived, full-duplex TCP connection over which either side can send messages at any time. This page covers how the protocol works, how an ordinary HTTP connection is upgraded into a WebSocket, and how to build servers and clients with the popular ws library and the now-built-in WebSocket client.
How the WebSocket protocol works
A WebSocket connection always begins life as a normal HTTP request. The client sends a GET request carrying an Upgrade: websocket header and a random Sec-WebSocket-Key. If the server agrees, it responds with status 101 Switching Protocols and a matching Sec-WebSocket-Accept value derived from that key. From that point on the bytes flowing over the TCP socket are no longer HTTP — they are WebSocket frames, a lightweight binary framing format that carries text or binary payloads in both directions.
The handshake looks like this on the wire:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The URL scheme is ws:// for plaintext and wss:// for TLS-encrypted connections (analogous to http:// and https://). Always prefer wss:// in production.
Node’s built-in
httpmodule does not implement the WebSocket server protocol. It exposes the rawupgradeevent and socket, but parsing frames by hand is error-prone. Use a battle-tested library likewsfor servers.
Building a server with the ws library
The ws package is the de-facto WebSocket implementation for Node. Install it first:
npm install ws
A minimal echo server that broadcasts every message to all connected clients:
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (socket, request) => {
console.log(`Client connected from ${request.socket.remoteAddress}`);
socket.on('message', (data, isBinary) => {
const text = isBinary ? data : data.toString();
console.log('Received:', text);
// Broadcast to every open client
for (const client of wss.clients) {
if (client.readyState === client.OPEN) {
client.send(text);
}
}
});
socket.on('close', () => console.log('Client disconnected'));
socket.on('error', (err) => console.error('Socket error:', err));
socket.send('Welcome to the server!');
});
console.log('WebSocket server listening on ws://localhost:8080');
Output:
WebSocket server listening on ws://localhost:8080
Client connected from ::ffff:127.0.0.1
Received: Hello everyone
The message event delivers a Buffer; the isBinary flag tells you whether the frame was a text or binary frame so you can decide whether to call .toString(). The wss.clients set gives you every live connection, which makes broadcasting trivial.
Sharing a port with an HTTP server
In real applications you usually want WebSockets and your HTTP/REST API on the same port. Create the WebSocketServer with { noServer: true } and wire it to the HTTP server’s upgrade event so you can authenticate or route by path before completing the handshake:
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';
const server = createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('HTTP and WebSockets share this port\n');
});
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
// Reject anything not destined for /chat
if (new URL(req.url, 'http://localhost').pathname !== '/chat') {
socket.write('HTTP/1.1 404 Not Found\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 http://localhost:3000'));
Connecting with a client
Modern Node.js (v22 ships it stable, v21 behind a flag) includes a global WebSocket class matching the browser API — no dependency required:
const ws = new WebSocket('ws://localhost:8080');
ws.addEventListener('open', () => {
console.log('Connected');
ws.send('Hello everyone');
});
ws.addEventListener('message', (event) => {
console.log('Server says:', event.data);
});
ws.addEventListener('close', () => console.log('Connection closed'));
ws.addEventListener('error', (err) => console.error('Error:', err));
The ws library also ships a client (new WebSocket(url) from import { WebSocket } from 'ws') that uses an EventEmitter-style API (ws.on('open', ...)) and supports custom headers, proxies, and other options the global client lacks.
| Feature | Global WebSocket | ws package |
|---|---|---|
| Built into Node | Yes (v22+ stable) | No, npm install ws |
| API style | Browser events (addEventListener) | Node EventEmitter (on) |
| Server support | No | Yes (WebSocketServer) |
| Custom headers / per-message deflate | Limited | Full control |
Keeping connections healthy
WebSocket connections can silently die (a laptop sleeps, a NAT drops the mapping) without firing a close event. The protocol defines ping/pong control frames to detect this. Send a ping periodically and terminate sockets that fail to pong back:
function heartbeat() {
this.isAlive = true;
}
wss.on('connection', (socket) => {
socket.isAlive = true;
socket.on('pong', heartbeat);
});
const interval = setInterval(() => {
for (const socket of wss.clients) {
if (!socket.isAlive) return socket.terminate();
socket.isAlive = false;
socket.ping();
}
}, 30000);
wss.on('close', () => clearInterval(interval));
Browsers and the global client answer pings automatically — you only call
.ping()from the server. Use.terminate()(not.close()) to forcibly kill an unresponsive socket immediately.
Best Practices
- Always use
wss://(TLS) in production; treat plaintextws://as local-development only. - Validate and authenticate during the HTTP
upgradehandshake — check tokens, origins, and paths before completing it, since once upgraded there is no per-message auth. - Never trust incoming payloads: validate and size-limit messages, and set
maxPayloadon theWebSocketServerto guard against memory exhaustion. - Implement ping/pong heartbeats to detect and clean up dead connections.
- Always handle the
errorevent on every socket and server; an unhandled WebSocket error can crash the process. - Send structured data as JSON (
JSON.stringify/JSON.parse) and namespace messages with atypefield so the client can route them. - For horizontal scaling across multiple Node instances, fan out messages through a shared backplane such as Redis pub/sub, since
wss.clientsonly knows about connections on the local process.