Two-Way, Persistent Connections — Alongside Express
WebSockets
Add WebSocket support to an Express server using the `ws` package.
What you'll learn
- Share a port between Express and WS
- Send and receive messages
- Broadcast to all clients
Express speaks HTTP. WebSockets are a separate protocol that
upgrades from HTTP. Use the ws package to layer WebSockets on
the same Node server.
Install
npm install ws Sharing the Port
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { buildApp } from "./app.js";
const app = buildApp();
// 1. Wrap the Express app in a raw HTTP server so we control upgrades
const httpServer = createServer(app);
// 2. Create a WebSocket server but don't bind a port
const wss = new WebSocketServer({ noServer: true });
// 3. Manually route upgrades
httpServer.on("upgrade", (req, socket, head) => {
if (req.url === "/ws") {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
} else {
socket.destroy();
}
});
// 4. Handle connections
wss.on("connection", (ws, req) => {
console.log("client connected");
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
ws.send(JSON.stringify({ echo: msg }));
});
ws.on("close", () => console.log("client gone"));
});
httpServer.listen(3000); Same port serves HTTP routes AND WebSocket connections.
The Client
const ws = new WebSocket("ws://localhost:3000/ws");
ws.onopen = () => ws.send(JSON.stringify({ hello: "server" }));
ws.onmessage = (e) => console.log("got", JSON.parse(e.data));
ws.onclose = () => console.log("disconnected"); Broadcasting
wss.on("connection", (ws) => {
ws.on("message", (raw) => {
// broadcast to all other clients
for (const client of wss.clients) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(raw.toString());
}
}
});
}); The skeleton of a chat server.
Authentication
WebSockets don’t have headers in the way HTTP does. Common pattern: auth at the upgrade step using cookies or a token in the query string.
httpServer.on("upgrade", async (req, socket, head) => {
const url = new URL(req.url, "http://localhost");
const token = url.searchParams.get("token");
try {
const user = jwt.verify(token, SECRET);
wss.handleUpgrade(req, socket, head, (ws) => {
ws.user = user;
wss.emit("connection", ws, req);
});
} catch {
socket.destroy();
}
}); Scaling — Pub/Sub
With multiple Node instances, broadcast must go through a shared message bus. Redis pub/sub is the simplest:
Client A connects to Server 1
Client B connects to Server 2
Server 1 publishes to Redis
Both servers receive, fan out to their clients
We touched on this in the Node track’s pub/sub lesson.
When To Use
- Chat, multiplayer games, collaborative editing
- Live state sync (Figma, Notion, Linear)
- Trading dashboards
- Anywhere clients need to send events frequently
For server → client only, SSE is simpler.
Socket.IO
A heavier alternative — rooms, namespaces, auto-reconnect, fallback transports. Useful when:
- You need rooms and broadcasting out of the box
- You support old browsers
- You want connection resilience
For modern clients (every browser since 2015), plain ws is
sufficient — and 10x lighter.