Raw WebSockets with ws
The ws library is the most popular WebSocket implementation in the Node.js ecosystem, and it deliberately stays close to the metal. It gives you a thin, fast layer over the WebSocket protocol — no rooms, no automatic reconnection, no fallback transports — which makes it ideal when you want full control and minimal overhead. This page shows how to attach a ws server to the same http.Server that Express already runs on, handle the HTTP Upgrade handshake, exchange messages, and manage the lifecycle of every connection.
Installing ws
ws is a single dependency with no compiled native parts, so it installs cleanly everywhere.
npm install express ws
It ships its own TypeScript types, so a TypeScript project needs nothing extra. For everyday use you import two things: WebSocketServer (the server) and, occasionally, WebSocket (the constant namespace that holds readyState values like WebSocket.OPEN).
Sharing the Express HTTP server
app.listen() quietly creates a Node http.Server for you, but to bolt a WebSocket server onto the same port you need a reference to that server. Create it explicitly with http.createServer(app) and pass it to WebSocketServer. Express keeps handling ordinary requests, while ws watches for the Upgrade handshake on the same socket.
const express = require('express');
const http = require('http');
const { WebSocketServer } = require('ws');
const app = express();
app.get('/', (req, res) => res.send('HTTP still works'));
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
wss.on('connection', (socket, req) => {
console.log('client connected from', req.socket.remoteAddress);
socket.send(JSON.stringify({ type: 'welcome', id: Date.now() }));
});
server.listen(3000, () => console.log('HTTP + WS on :3000'));
The connection event hands you the per-client socket plus the original req (the HTTP upgrade request), which is your only chance to read headers, cookies, or the URL for that client.
Sending and receiving messages
Each connection emits message, close, and error events, and exposes socket.send() to push data back. Messages arrive as a Buffer (or array of buffers) by default — ws does not parse JSON for you, so decode explicitly. The isBinary flag tells you whether the frame was text or binary.
wss.on('connection', (socket) => {
socket.on('message', (data, isBinary) => {
if (isBinary) return; // ignore binary frames here
let msg;
try {
msg = JSON.parse(data.toString());
} catch {
socket.send(JSON.stringify({ error: 'invalid JSON' }));
return;
}
socket.send(JSON.stringify({ echo: msg }));
});
socket.on('close', (code, reason) => {
console.log('closed', code, reason.toString());
});
socket.on('error', (err) => console.error('socket error', err.message));
});
Output:
// client sends: {"text":"hello"}
// client receives: {"echo":{"text":"hello"}}
closed 1000 client done
Broadcasting to every client
ws exposes the live set of connections as wss.clients, a Set. Iterate it to broadcast, but always check readyState first — a socket can be mid-close, and writing to it throws.
const { WebSocket } = require('ws');
function broadcast(wss, payload) {
const data = JSON.stringify(payload);
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
Handling the upgrade event manually
Because the Upgrade request never passes through Express, your app.use middleware — auth, CORS, logging — does not run on it. To authenticate or route by path, create the server with { noServer: true } and own the upgrade event yourself. You then call handleUpgrade only for requests you accept, and destroy the socket for the rest.
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const { pathname } = new URL(req.url, 'http://localhost');
if (pathname !== '/ws' || !isAuthorized(req)) {
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);
});
});
function isAuthorized(req) {
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
return token === process.env.WS_TOKEN;
}
This is the idiomatic place to validate a token, parse cookies, or reject by origin — it is the WebSocket equivalent of an auth middleware.
Detecting dead connections with heartbeats
TCP can drop silently: a client vanishes (laptop closes, network blips) without ever sending a close frame, leaving a zombie connection that consumes memory. ws solves this with ping/pong. Mark each socket alive on every pong, and periodically terminate any that did not respond.
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 === false) {
socket.terminate();
continue;
}
socket.isAlive = false;
socket.ping();
}
}, 30000);
wss.on('close', () => clearInterval(interval));
ws vs Socket.IO at a glance
| Feature | ws | Socket.IO |
|---|---|---|
| Protocol | Raw WebSocket only | Custom protocol over WS + HTTP fallback |
| Rooms / namespaces | Build yourself | Built in |
| Auto-reconnect | No | Yes |
| Bundle size | Tiny | Larger client + server |
| Best for | Performance, full control | Productivity, broad browser support |
Warning: The
upgraderequest bypasses every piece of Express middleware. Never assume your global auth or rate limiter protected a WebSocket — enforce both explicitly in theupgradehandler.
Best Practices
- Always parse incoming messages defensively —
wsdelivers raw buffers, so wrapJSON.parsein atry/catchand reject malformed frames. - Check
socket.readyState === WebSocket.OPENbefore everysend, especially when broadcasting acrosswss.clients. - Use
{ noServer: true }and your ownupgradehandler whenever you need authentication, path routing, or origin checks. - Run a ping/pong heartbeat to reap dead connections; without it, leaked sockets accumulate over time.
- Set
maxPayloadon theWebSocketServerto cap message size and blunt memory-exhaustion attacks. - Clean up timers and intervals in the
wss.on('close')andsocket.on('close')handlers to prevent leaks.