n4nAI

Building a WebSocket chat server with Express and ws

A practical, step-by-step tutorial for building an express.js websocket chat server ws with Express and the ws library, plus LLM integration.

n4n Team2 min read502 words

Audio narration

Coming soon — every post will get a voice note here.

Most WebSocket chat samples broadcast every message to every socket and call it done. This tutorial builds a real express.js websocket chat server ws with Express and the ws library, adding room isolation, typed messages, and an optional LLM hook so you can drop it into a backend that talks to language models.

Prerequisites

  • Node.js 18+ (for native fetch and stable WebSocketServer)
  • npm 9+
  • Basic familiarity with Express and ES modules
  • A terminal and a browser for local testing

Project Setup

Create a directory and install dependencies:

mkdir ws-chat && cd ws-chat
npm init -y
npm install express ws

Open package.json and set "type": "module" so we can use import syntax without flags.

Express HTTP Server

Start with a plain Express app that serves static files and a health check. We create the HTTP server explicitly because the WebSocket server will share it.

// server.js
import express from 'express';
import { createServer } from 'http';

const app = express();
app.use(express.static('public'));
app.get('/health', (_req, res) => res.json({ ok: true }));

const server = createServer(app);
server.listen(3000, () => console.log('HTTP on :3000'));

Run node server.js and verify with curl localhost:3000/health. Expected output:

{"ok":true}

Attaching the WebSocket Server

The ws package attaches to an existing HTTP server, so Express handles the upgrade handshake on the same port. This is the core of the express.js websocket chat server ws.

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ server });
wss.on('connection', (socket) => {
  console.log('client connected');
  socket.on('close', () => console.log('client left'));
});

Message Shape

Define a minimal JSON protocol. Clients send either a join or a msg event:

{
  "type": "msg",
  "room": "general",
  "text": "hello"
}

Server broadcasts to sockets subscribed to that room only.

Handling Connections and Rooms

Track sockets per room with a Map. On join, register the socket. On msg, broadcast to the room.

const rooms = new Map();

function joinRoom(socket, room) {
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room).add(socket);
  socket.room = room;
}

function broadcast(room, payload) {
  const targets = rooms.get(room);
  if (!targets) return;
  for (const sock of targets) {
    if (sock.readyState === sock.OPEN) sock.send(JSON.stringify(payload));
  }
}

wss.on('connection', (socket) => {
  socket.on('message', (raw) => {
    let msg;
    try { msg = JSON.parse(raw); } catch { return; }
    if (msg.type === 'join') joinRoom(socket, msg.room);
    if (msg.type === 'msg') broadcast(msg.room, { type: 'msg', from: 'user', text: msg.text });
  });
  socket.on('close', () => {
    if (socket.room) rooms.get(socket.room)?.delete(socket);
  });
});

Serving a Minimal Client

Create public/index.html with a tiny UI that joins general and sends messages.

<!doctype html>
<html>
<body>
  <input id="text" /><button onclick="send()">Send</button>
  <pre id="log"></pre>
  <script>
    const ws = new WebSocket('ws://localhost:3000');
    ws.onopen = () => ws.send(JSON.stringify({ type: 'join', room: 'general' }));
    ws.onmessage = (e) => {
      const m = JSON.parse(e.data);
      document.getElementById('log').textContent += '\n' + m.text;
    };
    function send() {
      const text = document.getElementById('text').value;
      ws.send(JSON.stringify({ type: 'msg', room: 'general', text }));
    }
  </script>
</body>
</html>

Verifying the Server

Start the server, open two browser tabs at localhost:3000. Type in one tab; the other displays the text. Server logs show connection events. Expected client log in the second tab:

hello
world

This confirms the express.js websocket chat server ws routes messages per room without leaking to other rooms.

Adding Authentication

In production you rarely allow anonymous sockets. Pass a token in the connection URL and validate it before joining.

wss.on('connection', (socket, req) => {
  const url = new URL(req.url, 'http://localhost');
  const token = url.searchParams.get('token');
  if (!isValid(token)) {
    socket.close(1008, 'unauthorized');
    return;
  }
  // proceed with message handling
});

The client connects with new WebSocket('ws://localhost:3000?token=...'). Reject invalid tokens immediately to avoid wasted memory.

Wiring an LLM Responder

A chat server gets more useful when a bot replies. Because this is an Express.js LLM backend integration, we add a server-side handler that calls an OpenAI-compatible endpoint when a message starts with @bot.

If you need model redundancy and per-token metering without custom provider code, an OpenAI-compatible gateway such as n4n.ai exposes one endpoint that fronts 240+ models and fails over automatically.

async function llmReply(prompt) {
  const res = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.OPENAI_KEY}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages: [{ role: 'user', content: prompt }],
    }),
  });
  const data = await res.json();
  return data.choices[0].message.content;
}

Hook it into the existing message handler:

if (msg.type === 'msg' && msg.text.startsWith('@bot ')) {
  const reply = await llmReply(msg.text.slice(5));
  broadcast(msg.room, { type: 'msg', from: 'bot', text: reply });
}

Streaming Tokens

For a real-time feel, set stream: true and parse Server-Sent Events chunks, broadcasting each token as it arrives:

const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const lines = decoder.decode(value).split('\n');
  for (const line of lines) {
    if (line.startsWith('data: ') && line !== 'data: [DONE]') {
      const token = JSON.parse(line.slice(6)).choices[0].delta.content;
      if (token) broadcast(msg.room, { type: 'token', from: 'bot', text: token });
    }
  }
}

The client appends tokens to the last bot message instead of creating new lines.

Production Notes

  • Use ws ping/pong to terminate dead sockets: socket.on('ping', () => socket.pong()) and set wss.options.clientTracking with a timeout.
  • Put Express behind nginx with proxy_set_header Upgrade $http_upgrade; to forward WebSocket traffic.
  • Cap message size: new WebSocketServer({ server, maxPayload: 1024 * 16 }).
  • For multi-instance scaling, replace the rooms Map with Redis pub/sub so broadcasts cross processes.
  • Never trust client room names; validate against a allowlist or sanitize.

The express.js websocket chat server ws we built handles rooms, typed messages, auth, and an LLM bridge. Swap the LLM endpoint for your provider of choice and ship it.

Tagsexpressjswebsocketschatreal-time

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All express.js llm backend integration posts →