WebSockets Cheatsheet

Sharing a Port with Express

Use this WebSockets reference while you build software engineering projects, review code for technical interview prep, or polish examples for a software engineer resume.

Why Share a Port

Running WebSocket on the same port as HTTP/Express avoids firewall/proxy issues and simplifies TLS termination — one certificate, one port (443 in production).

Attach ws to an Express http.Server

import express from "express";
import { createServer } from "http";
import { WebSocketServer } from "ws";

const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server }); // no `port` option

// REST routes
app.get("/health", (req, res) => res.json({ ok: true }));

// WebSocket handler
wss.on("connection", (ws, req) => {
  ws.send("connected");
  ws.on("message", (data) => ws.send(data)); // echo
});

server.listen(4000, () => console.log("HTTP + WS on :4000"));

Pass server (not port) to WebSocketServer. The ws library hooks into the HTTP server's upgrade event.

Path-Based Routing (multiple WebSocket endpoints)

import { WebSocketServer } from "ws";

const wsChat = new WebSocketServer({ noServer: true });
const wsLive = new WebSocketServer({ noServer: true });

server.on("upgrade", (req, socket, head) => {
  const { pathname } = new URL(req.url, "http://x");

  if (pathname === "/ws/chat") {
    wsChat.handleUpgrade(req, socket, head, (ws) => {
      wsChat.emit("connection", ws, req);
    });
  } else if (pathname === "/ws/live") {
    wsLive.handleUpgrade(req, socket, head, (ws) => {
      wsLive.emit("connection", ws, req);
    });
  } else {
    socket.destroy(); // reject unknown paths
  }
});

wsChat.on("connection", (ws) => { /* chat logic */ });
wsLive.on("connection", (ws) => { /* live collab logic */ });

noServer Mode — Full Manual Control

// noServer: true — ws never binds a port or touches server.on("upgrade")
const wss = new WebSocketServer({ noServer: true });

server.on("upgrade", (req, socket, head) => {
  // Optional: authenticate before upgrade
  const token = new URL(req.url, "http://x").searchParams.get("token");
  if (!isValidToken(token)) {
    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);
  });
});

Express Middleware Before Upgrade

Express middleware does not run for WebSocket upgrade requests (they are not HTTP requests at the ws layer). Run auth inside the upgrade handler instead.

// Pattern: parse session cookie in upgrade handler
import { parse as parseCookie } from "cookie";
import jwt from "jsonwebtoken";

server.on("upgrade", (req, socket, head) => {
  const cookies = parseCookie(req.headers.cookie ?? "");
  const token = cookies["hu_session"];

  let user;
  try {
    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) => {
    ws.user = user;
    wss.emit("connection", ws, req);
  });
});

Using express-ws

npm install express-ws
import expressWs from "express-ws";
import express from "express";

const app = express();
expressWs(app); // patches app in-place

app.get("/api/status", (req, res) => res.json({ ok: true }));

// WebSocket route — same router style as Express
app.ws("/ws/chat", (ws, req) => {
  ws.on("message", (msg) => {
    ws.send(`echo: ${msg}`);
  });
});

app.listen(4000);

express-ws wraps noServer mode behind an Express-router interface. Useful for familiarity, but ws + noServer gives more control.

HTTPS / WSS Setup

import { createServer as createHttpsServer } from "https";
import { readFileSync } from "fs";

const httpsServer = createHttpsServer({
  key: readFileSync("./certs/key.pem"),
  cert: readFileSync("./certs/cert.pem"),
});

const wss = new WebSocketServer({ server: httpsServer });
app.use(/* ... */);
httpsServer.on("request", app);
httpsServer.listen(443);

In production, terminate TLS at a reverse proxy (nginx/Caddy/AWS ALB) and run Node on plain HTTP internally.

nginx Reverse Proxy Configuration

server {
  listen 443 ssl;
  server_name api.example.com;

  ssl_certificate     /etc/ssl/cert.pem;
  ssl_certificate_key /etc/ssl/key.pem;

  location /ws/ {
    proxy_pass         http://localhost:4000;
    proxy_http_version 1.1;
    proxy_set_header   Upgrade    $http_upgrade;
    proxy_set_header   Connection "Upgrade";
    proxy_set_header   Host       $host;
    proxy_read_timeout 3600s;     # keep-alive for long-lived WS connections
    proxy_send_timeout 3600s;
  }

  location / {
    proxy_pass http://localhost:4000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }
}

Graceful Shutdown

process.on("SIGTERM", () => {
  console.log("Shutting down...");

  // Close WebSocket server — no new connections
  wss.close(() => {
    // Close HTTP server
    server.close(() => {
      process.exit(0);
    });
  });

  // Notify and close existing clients
  for (const client of wss.clients) {
    client.close(1001, "Server shutting down");
  }

  // Force-kill after timeout
  setTimeout(() => process.exit(1), 10_000);
});

Port-Sharing Reference

ApproachHowWhen to use
{ server } optionPass HTTP server to WebSocketServerSingle WS endpoint
noServer + manual upgradeHandle upgrade event yourselfMultiple endpoints, auth before upgrade
express-wsRouter-style .ws() methodExpress-centric codebases
Separate port{ port: 4001 } in WebSocketServerSimple setups, separate TLS not needed

Gotchas

  • Do not use app.use() for WebSocket auth — Express middleware only handles HTTP requests, not raw TCP upgrades.
  • The upgrade event fires for any protocol upgrade, including HTTP/2 — check req.headers.upgrade === "websocket" if you handle both.
  • socket.destroy() in the upgrade handler must be called without writing anything, or the browser will show a confusing error; write a rejection response first if you want meaningful logging on the client.
  • With { server } mode and Express, the upgrade event is consumed by ws — you cannot also listen to it unless you use noServer.
  • proxy_read_timeout in nginx defaults to 60 s — increase it for long-lived WebSocket connections or they will be silently dropped.