Skip to content

WebSocket with Express.js: ws Library Integration Guide

Express is an HTTP framework. It handles requests and sends responses. WebSockets are a different protocol that starts as HTTP and then upgrades to a persistent, bidirectional connection. Express knows nothing about that upgrade, so you need ws to handle it.

The standard pattern creates one HTTP server and attaches both Express and ws to it:

import express from "express";
import { createServer } from "http";
import { WebSocketServer } from "ws";
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
wss.on("connection", (ws, req) => {
console.log("client connected from", req.socket.remoteAddress);
ws.on("message", (data) => ws.send(`echo: ${data}`));
ws.on("close", () => console.log("client disconnected"));
});
app.get("/health", (req, res) => res.json({ status: "ok" }));
server.listen(3000, () => console.log("listening on :3000"));

Do not call app.listen() — that creates a separate HTTP server. Use server.listen() instead so both Express routes and WebSocket connections share the same port.

If you need WebSocket endpoints on specific paths (say /ws/chat and /ws/notifications), use noServer: true and handle the upgrade event yourself:

const chatWss = new WebSocketServer({ noServer: true });
const notifyWss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url, "http://localhost");
if (pathname === "/ws/chat") {
chatWss.handleUpgrade(req, socket, head, (ws) => {
chatWss.emit("connection", ws, req);
});
} else if (pathname === "/ws/notifications") {
notifyWss.handleUpgrade(req, socket, head, (ws) => {
notifyWss.emit("connection", ws, req);
});
} else {
socket.destroy();
}
});

This is the pattern to use when you want multiple WebSocket endpoints with different behavior. The alternative — one WebSocketServer with if/else branching inside the connection handler — gets messy fast.

You will find express-ws in old tutorials. It lets you write app.ws('/path', handler) like a normal Express route. Convenient, but the library has not been updated since 2020 and has unpatched issues. More importantly, it creates a false mental model: it makes WebSocket handlers look like Express middleware, but they do not participate in the middleware chain.

Use ws directly. The setup is five extra lines, and you get full control over the upgrade lifecycle.

Express middleware does not run on WebSocket connections. This is the single most common mistake. Your passport.authenticate(), your JWT middleware, your rate limiter — none of it applies to the upgrade request. You must verify credentials in the upgrade event handler, before calling handleUpgrade:

import jwt from "jsonwebtoken";
server.on("upgrade", (req, socket, head) => {
const token = new URL(req.url, "http://localhost")
.searchParams.get("token");
if (!token) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
} catch {
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);
});
});

Pass the token as a query parameter, not in headers. The browser WebSocket API does not allow custom headers — this is a protocol limitation, not a library limitation. Cookies work as an alternative if you control both domains.

Check req.headers.origin in the upgrade handler to block cross-origin WebSocket connections. Without this, any page can open a WebSocket to your server and ride on the user’s cookies:

server.on("upgrade", (req, socket, head) => {
const origin = req.headers.origin;
const allowed = ["https://myapp.com", "https://staging.myapp.com"];
if (!allowed.includes(origin)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});

CORS headers do not protect WebSocket connections. Browsers enforce CORS for fetch and XMLHttpRequest, but the WebSocket handshake bypasses CORS entirely. Origin checking in the upgrade handler is your only defense.

The simplest approach iterates wss.clients:

function broadcast(data) {
const msg = JSON.stringify(data);
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(msg);
}
}
}

For targeted messaging (send to a specific user, a room, or a subset), track connections in a Map:

const clients = new Map();
wss.on("connection", (ws, req) => {
const userId = req.user.id;
clients.set(userId, ws);
ws.on("close", () => clients.delete(userId));
});
function sendToUser(userId, data) {
const ws = clients.get(userId);
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}

If a user opens multiple tabs, store an array or Set of connections per user ID instead of a single reference.

TCP does not notify you when a connection drops silently (e.g., a mobile user walks into a tunnel). Without heartbeats, your clients Map fills with dead connections that consume memory and cause failed sends.

const HEARTBEAT_INTERVAL = 30_000;
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
});
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_INTERVAL);
wss.on("close", () => clearInterval(interval));

30 seconds is a reasonable default. Shorter intervals detect dead connections faster but add bandwidth overhead. For mobile-heavy apps, consider 20 seconds.

Node.js runs on a single thread. To use multiple CPU cores, you run multiple processes with pm2 or the built-in cluster module. The problem: WebSocket connections are stateful. A client connects to worker A, but the next HTTP request (or reconnection) might hit worker B, which knows nothing about that client.

You need sticky sessions — routing the same client to the same worker. Configure this in your ecosystem.config.js:

ecosystem.config.js
module.exports = {
apps: [{
name: "ws-app",
script: "app.js",
instances: "max",
exec_mode: "cluster",
}],
};

Then run pm2 start ecosystem.config.js. You also need sticky sessions at the load balancer level (Nginx ip_hash or ALB stickiness) because pm2’s built-in cluster does not handle WebSocket upgrade routing. Without sticky sessions, clients get 400 errors on reconnection because the upgrade request lands on a different worker that has no record of the connection. This is the number one scaling issue with WebSocket apps on Node.js.

Redis Pub/Sub for Multi-Process Broadcasting

Section titled “Redis Pub/Sub for Multi-Process Broadcasting”

Sticky sessions solve connection routing, but not broadcasting. If user A is connected to worker 1 and user B is connected to worker 2, broadcasting from worker 1 only reaches user A.

The fix: publish messages to Redis and subscribe in every worker.

import { createClient } from "redis";
const pub = createClient();
const sub = createClient();
await pub.connect();
await sub.connect();
await sub.subscribe("broadcast", (message) => {
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
function broadcastAll(data) {
pub.publish("broadcast", JSON.stringify(data));
}

Every worker subscribes to the broadcast channel. When any worker publishes, all workers receive the message and forward it to their local WebSocket clients. This pattern works with any pub/sub system — Redis is the most common because you probably already have it.

For production systems with thousands of connections and complex routing requirements, consider a managed realtime service like Ably, or alternatives like Pusher or PubNub, rather than building and operating the pub/sub infrastructure yourself.

Socket.IO with Express: When It Makes Sense

Section titled “Socket.IO with Express: When It Makes Sense”

Socket.IO adds a layer on top of WebSockets: automatic reconnection, rooms, namespaces, binary support, and HTTP long-polling fallback. If you need those features, Socket.IO saves you from building them yourself.

The trade-off: Socket.IO uses its own protocol. Standard WebSocket clients cannot connect to a Socket.IO server. You are locked into the Socket.IO client library on every platform.

import { Server } from "socket.io";
const io = new Server(server, {
cors: { origin: "https://myapp.com" },
});
io.on("connection", (socket) => {
socket.join("room-1");
socket.to("room-1").emit("message", "hello room");
});

Use Socket.IO when you need rooms, namespaces, or guaranteed delivery with acknowledgements. Use raw ws when you want a standard WebSocket server that any client can connect to, or when you need the lowest possible latency and overhead.

The most frequent Express + WebSocket bug: creating a WebSocketServer without attaching it to the HTTP server or handling the upgrade event. The client sends an upgrade request, Express does not know what to do with it, and the client gets a 400 response.

The fix is always one of:

  1. Pass { server } to WebSocketServer (simplest)
  2. Use { noServer: true } and listen for "upgrade" on the HTTP server (more control)

If you see Error: Unexpected server response: 400 in the client, check that you are using createServer(app) and passing that server to ws, not calling app.listen() separately.

No, and it probably never will. Express is built on Node’s HTTP module, which handles request/response pairs. WebSockets are a different protocol with persistent connections. The ws library handles the WebSocket protocol and connects to the same HTTP server that Express uses. The express-ws package attempted to bridge this gap with app.ws() syntax, but it was abandoned in 2020 and should not be used in new projects.

Why do I get 400 errors when connecting WebSocket to Express?

Section titled “Why do I get 400 errors when connecting WebSocket to Express?”

The HTTP upgrade request arrives at Node’s HTTP server. If nothing handles the upgrade event, Express processes it as a regular HTTP request and returns 400 because it cannot match the upgrade to any route. Fix it by either passing the server instance to new WebSocketServer({ server }) or by using noServer: true and manually handling server.on("upgrade", ...). The second approach gives you control over path routing and authentication before the upgrade completes.

Does Express middleware run on WebSocket connections?

Section titled “Does Express middleware run on WebSocket connections?”

No. This catches everyone. Express middleware (body parsers, CORS, session handling, authentication) runs exclusively on HTTP request/response cycles. The WebSocket upgrade bypasses the Express middleware stack entirely. You must implement authentication, rate limiting, and origin validation in the upgrade event handler. If you use Passport or JWT middleware in Express, you need separate verification logic for WebSocket connections.

How do I scale Express WebSocket apps across processes?

Section titled “How do I scale Express WebSocket apps across processes?”

WebSocket connections are long-lived and stateful — they are pinned to the process that accepted the upgrade. With pm2 or Node’s cluster module, you need sticky sessions to ensure reconnections go back to the same worker. For broadcasting across workers, add a Redis pub/sub layer: each worker subscribes and forwards messages to its local clients. Without sticky sessions, reconnections get 400 errors. Without pub/sub, broadcasts only reach clients on one worker.

Should I use Socket.IO or raw ws with Express?

Section titled “Should I use Socket.IO or raw ws with Express?”

Default to ws. It is lighter (no protocol overhead), faster (no encoding layer), and works with any WebSocket client. Socket.IO makes sense when you specifically need rooms, namespaces, automatic reconnection with buffering, or HTTP long-polling fallback for environments where WebSockets are blocked. The cost is lock-in: Socket.IO uses a custom protocol, so only Socket.IO clients can connect. For most Express APIs adding real-time features, ws with a simple reconnection wrapper on the client is enough.