Skip to content
Express.js ex realtime 4 min read

Raw WebSockets with ws

The ws library is the most popular WebSocket implementation in the Node.js ecosystem, and it deliberately stays close to the metal. It gives you a thin, fast layer over the WebSocket protocol — no rooms, no automatic reconnection, no fallback transports — which makes it ideal when you want full control and minimal overhead. This page shows how to attach a ws server to the same http.Server that Express already runs on, handle the HTTP Upgrade handshake, exchange messages, and manage the lifecycle of every connection.

Installing ws

ws is a single dependency with no compiled native parts, so it installs cleanly everywhere.

npm install express ws

It ships its own TypeScript types, so a TypeScript project needs nothing extra. For everyday use you import two things: WebSocketServer (the server) and, occasionally, WebSocket (the constant namespace that holds readyState values like WebSocket.OPEN).

Sharing the Express HTTP server

app.listen() quietly creates a Node http.Server for you, but to bolt a WebSocket server onto the same port you need a reference to that server. Create it explicitly with http.createServer(app) and pass it to WebSocketServer. Express keeps handling ordinary requests, while ws watches for the Upgrade handshake on the same socket.

const express = require('express');
const http = require('http');
const { WebSocketServer } = require('ws');

const app = express();
app.get('/', (req, res) => res.send('HTTP still works'));

const server = http.createServer(app);
const wss = new WebSocketServer({ server });

wss.on('connection', (socket, req) => {
  console.log('client connected from', req.socket.remoteAddress);
  socket.send(JSON.stringify({ type: 'welcome', id: Date.now() }));
});

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

The connection event hands you the per-client socket plus the original req (the HTTP upgrade request), which is your only chance to read headers, cookies, or the URL for that client.

Sending and receiving messages

Each connection emits message, close, and error events, and exposes socket.send() to push data back. Messages arrive as a Buffer (or array of buffers) by default — ws does not parse JSON for you, so decode explicitly. The isBinary flag tells you whether the frame was text or binary.

wss.on('connection', (socket) => {
  socket.on('message', (data, isBinary) => {
    if (isBinary) return;                       // ignore binary frames here
    let msg;
    try {
      msg = JSON.parse(data.toString());
    } catch {
      socket.send(JSON.stringify({ error: 'invalid JSON' }));
      return;
    }
    socket.send(JSON.stringify({ echo: msg }));
  });

  socket.on('close', (code, reason) => {
    console.log('closed', code, reason.toString());
  });

  socket.on('error', (err) => console.error('socket error', err.message));
});

Output:

// client sends: {"text":"hello"}
// client receives: {"echo":{"text":"hello"}}
closed 1000 client done

Broadcasting to every client

ws exposes the live set of connections as wss.clients, a Set. Iterate it to broadcast, but always check readyState first — a socket can be mid-close, and writing to it throws.

const { WebSocket } = require('ws');

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

Handling the upgrade event manually

Because the Upgrade request never passes through Express, your app.use middleware — auth, CORS, logging — does not run on it. To authenticate or route by path, create the server with { noServer: true } and own the upgrade event yourself. You then call handleUpgrade only for requests you accept, and destroy the socket for the rest.

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

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

  if (pathname !== '/ws' || !isAuthorized(req)) {
    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);
  });
});

function isAuthorized(req) {
  const token = new URL(req.url, 'http://localhost').searchParams.get('token');
  return token === process.env.WS_TOKEN;
}

This is the idiomatic place to validate a token, parse cookies, or reject by origin — it is the WebSocket equivalent of an auth middleware.

Detecting dead connections with heartbeats

TCP can drop silently: a client vanishes (laptop closes, network blips) without ever sending a close frame, leaving a zombie connection that consumes memory. ws solves this with ping/pong. Mark each socket alive on every pong, and periodically terminate any that did not respond.

function heartbeat() { this.isAlive = true; }

wss.on('connection', (socket) => {
  socket.isAlive = true;
  socket.on('pong', heartbeat);
});

const interval = setInterval(() => {
  for (const socket of wss.clients) {
    if (socket.isAlive === false) {
      socket.terminate();
      continue;
    }
    socket.isAlive = false;
    socket.ping();
  }
}, 30000);

wss.on('close', () => clearInterval(interval));

ws vs Socket.IO at a glance

FeaturewsSocket.IO
ProtocolRaw WebSocket onlyCustom protocol over WS + HTTP fallback
Rooms / namespacesBuild yourselfBuilt in
Auto-reconnectNoYes
Bundle sizeTinyLarger client + server
Best forPerformance, full controlProductivity, broad browser support

Warning: The upgrade request bypasses every piece of Express middleware. Never assume your global auth or rate limiter protected a WebSocket — enforce both explicitly in the upgrade handler.

Best Practices

  • Always parse incoming messages defensively — ws delivers raw buffers, so wrap JSON.parse in a try/catch and reject malformed frames.
  • Check socket.readyState === WebSocket.OPEN before every send, especially when broadcasting across wss.clients.
  • Use { noServer: true } and your own upgrade handler whenever you need authentication, path routing, or origin checks.
  • Run a ping/pong heartbeat to reap dead connections; without it, leaked sockets accumulate over time.
  • Set maxPayload on the WebSocketServer to cap message size and blunt memory-exhaustion attacks.
  • Clean up timers and intervals in the wss.on('close') and socket.on('close') handlers to prevent leaks.
Last updated June 14, 2026
Was this helpful?