Running Multiple WebSocket Servers on the Same Node.js HTTP Server
I hit this today while adding a second WebSocket server to an existing Node.js app. The first server worked fine. The second one silently failed — WebSocket connections refused with no error on the server side. Here's exactly what happened and how I fixed it.
The Setup
I had an existing Node.js Express app with one WebSocket server for real-time streaming on /stream-a. I needed to add a second WebSocket server on a different path /stream-b.
The natural thing to do — create two WebSocketServer instances on the same HTTP server:
import { WebSocketServer } from 'ws';
const serverA = new WebSocketServer({ server: httpServer, path: '/stream-a' });
const serverB = new WebSocketServer({ server: httpServer, path: '/stream-b' });The Problem
After adding serverB, the browser showed:
WebSocket connection to 'wss://api.yourdomain.com/stream-b' failed:The backend showed zero logs — no connection attempt, no error. Server A continued working perfectly. Server B was completely silent.
Running a curl test confirmed the endpoint was reachable:
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
https://api.yourdomain.com/stream-b
# Returns:
HTTP/1.1 400 Bad Request
Missing or invalid Sec-WebSocket-Key headerThe 400 from the ws library meant the endpoint was reachable — but browser WebSocket connections were still failing.
Why It Happens
When you create a WebSocketServer with { server: httpServer }, the ws library attaches its own upgrade event listener to the HTTP server.
The problem: the first WebSocketServer's upgrade listener consumes the upgrade event. When a second server is attached the same way, it never receives the upgrade request because the first listener already handled it — and rejected it since the path didn't match.
// What ws does internally when you pass { server: httpServer }:
httpServer.on('upgrade', (req, socket, head) => {
// serverA handles ALL upgrade requests
// If path is /stream-b → serverA rejects it → serverB never sees it
});This is a known limitation of the ws library when multiple instances share the same HTTP server. The path option does NOT create separate isolated listeners — it just filters inside one shared listener.
The Fix — noServer: true
The solution is to take manual control of the HTTP upgrade event using noServer: true. Each WebSocket server opts out of auto-attaching to the HTTP server, and you route upgrade requests to the right server yourself.
import { WebSocketServer } from 'ws';
import { URL } from 'url';
// Both servers opt out of auto-attaching
const serverA = new WebSocketServer({ noServer: true });
const serverB = new WebSocketServer({ noServer: true });
// You handle the HTTP upgrade event once
httpServer.on('upgrade', (request, socket, head) => {
const { pathname } = new URL(request.url, 'http://localhost');
if (pathname === '/stream-a') {
serverA.handleUpgrade(request, socket, head, (ws) => {
serverA.emit('connection', ws, request);
});
return;
}
if (pathname === '/stream-b') {
serverB.handleUpgrade(request, socket, head, (ws) => {
serverB.emit('connection', ws, request);
});
return;
}
// Unknown path — destroy the socket
socket.destroy();
});Clean Pattern for Multiple Servers
If you have many WebSocket servers, a map-based router is cleaner:
const wsServers = new Map([
['/stream-a', new WebSocketServer({ noServer: true })],
['/stream-b', new WebSocketServer({ noServer: true })],
['/stream-c', new WebSocketServer({ noServer: true })],
]);
httpServer.on('upgrade', (request, socket, head) => {
const { pathname } = new URL(request.url, 'http://localhost');
const wss = wsServers.get(pathname);
if (!wss) { socket.destroy(); return; }
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});Key Takeaways
- Never attach two
WebSocketServerinstances to the same HTTP server using{ server: httpServer }— only the first one will actually receive connections. - Use
{ noServer: true }on all instances and handle routing yourself via a singlehttpServer.on('upgrade', ...)listener. - The curl 400 "Missing Sec-WebSocket-Key" test is useful — it confirms the endpoint is reachable even when browser WebSocket fails.
- If a path doesn't match any WebSocket server, call
socket.destroy()to cleanly close the connection instead of leaving it hanging.