WebSockets

Two-Way, Persistent Connections — Alongside Express

WebSockets

Add WebSocket support to an Express server using the `ws` package.

4 min read Level 2/5 #express#websockets#ws
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.

GraphQL →