Handling the WebSocket upgrade in Express and Fastify #
You add new WebSocketServer({ port: 8081 }) next to your Express app and now run two ports, two TLS configurations and two sets of proxy rules. So you try to put WebSockets on the same port — and Express middleware never runs for the upgrade, app.use('/ws', …) does nothing, a second WebSocket path steals connections from the first, or requests hang because two handlers both think they own the socket. The reason is that the WebSocket handshake does not flow through Express’s request pipeline at all. It arrives on the underlying Node HTTP server as a separate upgrade event, and integrating it cleanly means handling that event deliberately: route by path, authenticate with the same logic your HTTP routes use, and make exactly one handler responsible for each socket.
Root cause #
Node’s http.Server emits request for ordinary requests and upgrade for requests carrying Upgrade headers. Express registers itself as the request listener, so its middleware stack — body parsers, sessions, authentication, CORS — never sees upgrade requests. That is why app.get('/ws') cannot handle a WebSocket and why session middleware appears not to work for sockets.
The ws library offers two ways to attach. Passing { server } makes the WebSocket server add its own upgrade listener and handle every upgrade on that server, optionally filtered by path. Passing { noServer: true } makes it passive: your code listens for upgrade, decides what to do, and calls handleUpgrade. The first is convenient for one endpoint; with two WebSocketServers both attached via { server }, both listeners fire for every upgrade and the one whose path does not match destroys the socket, which breaks the other. The second is the correct pattern whenever there is routing or authentication to do.
Resolution #
Create the HTTP server yourself, pass Express (or Fastify’s underlying server) as the request handler, and attach one upgrade listener that routes by path, authenticates, and hands the socket to the right noServer WebSocket server. Reuse your authentication by calling the same function your HTTP middleware uses, not by trying to run Express middleware on the upgrade.
import http from 'node:http';
import express from 'express';
import { WebSocketServer } from 'ws';
import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';
const app = express();
app.get('/api/health', (_req, res) => res.json({ ok: true }));
// One WebSocketServer per endpoint, none bound to the HTTP server directly.
const chat = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
const telemetry = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 });
const routes = new Map<string, WebSocketServer>([['/ws/chat', chat], ['/ws/telemetry', telemetry]]);
// The SAME function the HTTP auth middleware calls — one source of truth.
declare function authenticate(req: IncomingMessage): Promise<{ userId: string } | null>;
function refuse(socket: Duplex, code: number, text: string) {
socket.write(`HTTP/1.1 ${code} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
socket.destroy();
}
const server = http.createServer(app);
server.on('upgrade', async (req, socket, head) => {
// A socket error during async auth must not crash the process.
socket.on('error', () => socket.destroy());
const { pathname } = new URL(req.url ?? '/', 'http://internal');
const wss = routes.get(pathname);
if (!wss) return refuse(socket, 404, 'Not Found'); // unknown path: exactly one handler answers
const user = await authenticate(req);
if (!user) return refuse(socket, 401, 'Unauthorized');
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, user); // pass identity to connection handlers
});
});
chat.on('connection', (ws, _req, user: { userId: string }) => {
ws.on('message', (data) => { /* route chat messages for user.userId */ });
});
server.listen(8080);
Fastify is similar: its server is available as fastify.server after ready(), and you can attach the same upgrade listener to it. The @fastify/websocket plugin wraps this pattern and lets you declare WebSocket routes with { websocket: true }, running Fastify’s onRequest and preValidation hooks — including authentication plugins — before the upgrade, which is the main reason to prefer it over hand-wiring in Fastify apps:
import Fastify from 'fastify';
import websocket from '@fastify/websocket';
const fastify = Fastify();
await fastify.register(websocket, { options: { maxPayload: 64 * 1024 } });
fastify.addHook('onRequest', async (req, reply) => { // runs for upgrades too
if (req.url.startsWith('/ws') && !(await verifyToken(req))) reply.code(401).send();
});
fastify.get('/ws/chat', { websocket: true }, (socket, req) => {
socket.on('message', (msg) => socket.send(`echo: ${msg}`));
});
await fastify.listen({ port: 8080 });
declare function verifyToken(req: unknown): Promise<boolean>;
Authentication choices for the upgrade — tokens, cookies, tickets — are compared in validating JWT on the WebSocket upgrade and cookie session authentication for WebSockets. If you use express-session, you can run the session middleware manually against the upgrade request (sessionParser(req, {} as any, next)) to populate req.session, which keeps session logic in one place.
Edge cases #
Unanswered upgrades hang. If your upgrade listener returns without writing a response or destroying the socket — an unmatched path, an early return — the client waits until it times out. Every code path must end in handleUpgrade, a written HTTP error, or socket.destroy().
HTTPS and HTTP/2. Create an https.Server the same way for TLS. Node’s http2 server does not emit upgrade for WebSockets; if you serve HTTP/2, terminate WebSockets at a proxy or serve them from an HTTP/1.1 listener.
Dev servers and proxies. Vite and webpack dev servers proxy WebSockets only when configured (ws: true in the proxy options), and their own HMR socket shares the dev server — make sure your path does not collide with it.
Verification #
Test each path and each rejection with curl, since browsers hide the status codes of failed upgrades:
H=(-H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==')
curl -si --http1.1 "${H[@]}" http://localhost:8080/ws/chat --max-time 2 | head -1 # 401 without auth
curl -si --http1.1 "${H[@]}" -H "Authorization: Bearer $T" http://localhost:8080/ws/chat --max-time 2 | head -1 # 101
curl -si --http1.1 "${H[@]}" http://localhost:8080/ws/nope --max-time 2 | head -1 # 404, not a hang
curl -s http://localhost:8080/api/health # Express still works
A hang on any of these means a code path in the upgrade listener that neither answers nor destroys the socket.
Operational checklist #
FAQ #
Why doesn’t my Express middleware run for WebSocket connections? #
Upgrade requests are emitted by Node’s HTTP server as upgrade events, not request events, so Express never receives them. Handle the upgrade event and call your authentication logic directly.
How do I serve two WebSocket endpoints on one port? #
Create each WebSocketServer with noServer: true and attach a single upgrade listener that picks the server by path and calls its handleUpgrade. Attaching two servers with { server } makes them interfere.
Can I use express-ws? #
express-ws patches Express to route WebSocket upgrades through app.ws() routes and runs middleware for them. It works, but it is lightly maintained; the plain noServer pattern has fewer moving parts and no framework patching.
Does this work with Socket.IO? #
Socket.IO attaches its own upgrade and request handlers to the HTTP server under its path (/socket.io/ by default). Keep your raw WebSocket paths distinct and make sure your listener ignores Socket.IO’s path rather than refusing it.
Related #
- Debugging WebSocket Handshake Failures — when the upgrade still fails.
- Validating JWT on the WebSocket Upgrade — the authentication step in the listener.
- Building a WebSocket Message Router in TypeScript — what happens after the upgrade.
- Integration Testing a WebSocket Server — testing these paths end to end.
Back to Protocol Handshake Mechanics.