Series: Pure Node.js — Zero Dependencies

Post #9: Chat Rooms & Broadcast Messages

📅 March 2026 ⏱ 11 min read 🏷 Node.js, WebSockets, Real-time, Chat

📚 Pure Node.js Series — Zero Dependencies

#1: Build an HTTP Server From Scratch #2: Manual Routing — No Express Needed #3: Serving Static Files (HTML, CSS, JS) #4: Handling Forms & POST Requests #5: JSON Files as a Database #6: HTML Templating with String Interpolation #7: Vanilla JS Charts & Dynamic UI #8: WebSockets From Scratch → #9: Chat Rooms & Broadcast Messages (you are here) #10: Sessions & Cookies Without Packages #11: File Uploads & Image Serving #12: Deploy Pure Node.js to a VPS

Managing Connected Clients

In Post #8 we built the raw WebSocket connection. Now we build the chat logic on top: tracking clients, rooms, usernames, and broadcasting messages.

// chat.js — the core state

// Map of socket → client info
const clients = new Map();

// Map of roomName → Set of sockets
const rooms = new Map();

function addClient(socket) {
  clients.set(socket, {
    id: crypto.randomUUID(),
    username: null,
    room: null,
    connectedAt: Date.now()
  });
}

function removeClient(socket) {
  const client = clients.get(socket);
  if (client?.room) {
    leaveRoom(socket, client.room);
  }
  clients.delete(socket);
}

function getClient(socket) {
  return clients.get(socket);
}

Joining and Leaving Rooms

function joinRoom(socket, roomName) {
  const client = clients.get(socket);
  if (!client) return;

  // Leave current room if in one
  if (client.room) leaveRoom(socket, client.room);

  // Create room if it doesn't exist
  if (!rooms.has(roomName)) {
    rooms.set(roomName, new Set());
  }

  rooms.get(roomName).add(socket);
  client.room = roomName;

  // Notify others in room
  broadcast(roomName, {
    type: 'system',
    text: `${client.username} joined the room`,
    room: roomName,
    timestamp: Date.now()
  }, socket); // exclude the joining user
}

function leaveRoom(socket, roomName) {
  const client = clients.get(socket);
  const room = rooms.get(roomName);
  if (!room) return;

  room.delete(socket);
  client.room = null;

  // Clean up empty rooms
  if (room.size === 0) rooms.delete(roomName);
  else {
    broadcast(roomName, {
      type: 'system',
      text: `${client.username} left the room`,
      room: roomName,
      timestamp: Date.now()
    });
  }
}

Broadcasting Messages

const { sendMessage } = require('./ws');

function broadcast(roomName, message, excludeSocket = null) {
  const room = rooms.get(roomName);
  if (!room) return;

  const json = JSON.stringify(message);

  for (const socket of room) {
    if (socket === excludeSocket) continue;
    if (!socket.destroyed) {
      sendMessage(socket, json);
    }
  }
}

function sendToClient(socket, message) {
  if (!socket.destroyed) {
    sendMessage(socket, JSON.stringify(message));
  }
}

Handling Incoming Messages

function handleMessage(socket, raw) {
  let msg;
  try {
    msg = JSON.parse(raw);
  } catch {
    return; // ignore malformed messages
  }

  const client = clients.get(socket);

  switch (msg.type) {
    case 'register': {
      const username = String(msg.username || '').trim().slice(0, 20);
      if (!username) {
        sendToClient(socket, { type: 'error', text: 'Username required' });
        return;
      }
      client.username = username;
      sendToClient(socket, { type: 'registered', username });
      break;
    }

    case 'join': {
      if (!client.username) {
        sendToClient(socket, { type: 'error', text: 'Register first' });
        return;
      }
      const roomName = String(msg.room || 'general').trim().slice(0, 30);
      joinRoom(socket, roomName);
      sendToClient(socket, {
        type: 'joined',
        room: roomName,
        members: getRoomMembers(roomName)
      });
      break;
    }

    case 'message': {
      if (!client.username || !client.room) {
        sendToClient(socket, { type: 'error', text: 'Join a room first' });
        return;
      }
      const text = String(msg.text || '').trim().slice(0, 500);
      if (!text) return;

      broadcast(client.room, {
        type: 'message',
        username: client.username,
        text,
        room: client.room,
        timestamp: Date.now()
      });
      break;
    }
  }
}

function getRoomMembers(roomName) {
  const room = rooms.get(roomName);
  if (!room) return [];
  return [...room].map(s => clients.get(s)?.username).filter(Boolean);
}

The Vanilla JS Chat Frontend

// static/chat.js
const ws = new WebSocket(`ws://${location.host}`);
let myUsername = '';

ws.addEventListener('open', () => {
  const username = prompt('Enter your username:');
  myUsername = username;
  ws.send(JSON.stringify({ type: 'register', username }));
});

ws.addEventListener('message', (event) => {
  const msg = JSON.parse(event.data);
  switch (msg.type) {
    case 'registered':
      ws.send(JSON.stringify({ type: 'join', room: 'general' }));
      break;
    case 'message':
    case 'system':
      appendMessage(msg);
      break;
    case 'joined':
      document.getElementById('room-name').textContent = msg.room;
      document.getElementById('members').textContent =
        msg.members.join(', ');
      break;
  }
});

function appendMessage(msg) {
  const list = document.getElementById('messages');
  const li = document.createElement('li');
  li.className = msg.type === 'system' ? 'system-msg' : 'chat-msg';

  if (msg.type === 'message') {
    const time = new Date(msg.timestamp).toLocaleTimeString();
    li.innerHTML = `
      <span class="time">${time}</span>
      <span class="user">${msg.username}</span>
      <span class="text"></span>
    `;
    li.querySelector('.text').textContent = msg.text; // safe
  } else {
    li.textContent = msg.text;
  }

  list.appendChild(li);
  list.scrollTop = list.scrollHeight;
}

document.getElementById('send-form')
  .addEventListener('submit', (e) => {
    e.preventDefault();
    const input = document.getElementById('message-input');
    const text = input.value.trim();
    if (!text) return;
    ws.send(JSON.stringify({ type: 'message', text }));
    input.value = '';
  });

Wiring Into the Server

// server.js — upgrade handler
const chat = require('./chat');

server.on('upgrade', (req, socket) => {
  const ws = handleUpgrade(req, socket);
  chat.addClient(ws);

  ws.on('data', (buffer) => {
    const frame = parseFrame(buffer);
    if (frame.type === 'close') {
      chat.removeClient(ws);
      ws.destroy();
    } else if (frame.type === 'message') {
      chat.handleMessage(ws, frame.data);
    }
  });

  ws.on('close', () => chat.removeClient(ws));
  ws.on('error', () => chat.removeClient(ws));
});
Key takeaways:
  • Map of socket → client info — the backbone of connection tracking
  • Map of roomName → Set of sockets — clean room management
  • Broadcast = iterate room's Set, call sendMessage on each socket
  • JSON-based message protocol — { type, ... } switch pattern
  • Always textContent for user messages in the DOM — never innerHTML

What's Next

Module 3 complete! In Post #10, we start the mini Store — beginning with sessions and cookies, so users can stay logged in without any session management packages.

Building along? Share on X/Twitter or GitHub.