Build a Real-Time Chat App with Node.js and WebSockets in 2026: A Step-by-Step Guide

Real-time communication is everywhere — from Slack and Discord to live dashboards and collaborative editors. WebSockets remain the gold standard for bidirectional, low-latency communication on the web. In this hands-on guide, you’ll build a fully functional real-time chat application using Node.js and the modern ws library, complete with rooms, usernames, and message broadcasting.

Why WebSockets in 2026?

While Server-Sent Events (SSE) and HTTP/3 have gained traction, WebSockets still dominate use cases that require true two-way communication. Unlike HTTP polling, a WebSocket connection stays open, letting both server and client push messages instantly with minimal overhead — typically just 2 bytes of framing per message.

Project Setup

Let’s scaffold a new Node.js project. We’ll use Node 22+ (LTS in 2026) which has native TypeScript support via type stripping, but we’ll keep this example in plain JavaScript for accessibility.

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

That’s it — no Express needed. The ws library is lightweight, fast, and production-tested.

Building the WebSocket Server

Create server.js with room-based chat support:

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map(); // room name -> Set of clients

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.room = null;
  ws.username = 'Anonymous';

  ws.on('pong', () => { ws.isAlive = true; });

  ws.on('message', (data) => {
    let msg;
    try {
      msg = JSON.parse(data);
    } catch {
      ws.send(JSON.stringify({ type: 'error', text: 'Invalid JSON' }));
      return;
    }

    switch (msg.type) {
      case 'join':
        handleJoin(ws, msg);
        break;
      case 'chat':
        handleChat(ws, msg);
        break;
      case 'leave':
        handleLeave(ws);
        break;
      default:
        ws.send(JSON.stringify({ type: 'error', text: 'Unknown message type' }));
    }
  });

  ws.on('close', () => handleLeave(ws));
});

function handleJoin(ws, msg) {
  const room = msg.room || 'general';
  const username = msg.username || 'Anonymous';

  // Leave current room first
  if (ws.room) handleLeave(ws);

  ws.room = room;
  ws.username = username;

  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room).add(ws);

  broadcast(room, {
    type: 'system',
    text: `${username} joined #${room}`,
    users: [...rooms.get(room)].map(c => c.username)
  });
}

function handleChat(ws, msg) {
  if (!ws.room) {
    ws.send(JSON.stringify({ type: 'error', text: 'Join a room first' }));
    return;
  }
  broadcast(ws.room, {
    type: 'chat',
    username: ws.username,
    text: msg.text,
    timestamp: Date.now()
  });
}

function handleLeave(ws) {
  if (!ws.room) return;
  const room = ws.room;
  const clients = rooms.get(room);
  if (clients) {
    clients.delete(ws);
    if (clients.size === 0) rooms.delete(room);
    else broadcast(room, {
      type: 'system',
      text: `${ws.username} left #${room}`,
      users: [...clients].map(c => c.username)
    });
  }
  ws.room = null;
}

function broadcast(room, message) {
  const payload = JSON.stringify(message);
  const clients = rooms.get(room);
  if (!clients) return;
  for (const client of clients) {
    if (client.readyState === 1) client.send(payload);
  }
}

// Heartbeat: detect broken connections
const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

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

console.log('WebSocket server running on ws://localhost:8080');

Building a Browser Client

Create index.html — a minimal but functional chat UI:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Realtime Chat</title>
  <style>
    body { font-family: system-ui; max-width: 600px; margin: 2rem auto; }
    #messages { height: 400px; overflow-y: auto; border: 1px solid #ccc;
                padding: 1rem; margin: 1rem 0; border-radius: 8px; }
    .system { color: #888; font-style: italic; }
    input, button { padding: 0.5rem; font-size: 1rem; }
  </style>
</head>
<body>
  <h1>💬 Realtime Chat</h1>
  <div id="setup">
    <input id="username" placeholder="Username" />
    <input id="room" placeholder="Room (default: general)" />
    <button onclick="joinRoom()">Join</button>
  </div>
  <div id="messages"></div>
  <input id="input" placeholder="Type a message..." onkeydown="if(event.key==='Enter')sendMsg()" />
  <button onclick="sendMsg()">Send</button>

  <script>
    const ws = new WebSocket('ws://localhost:8080');
    const messages = document.getElementById('messages');

    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      const div = document.createElement('div');
      if (msg.type === 'system') {
        div.className = 'system';
        div.textContent = msg.text;
      } else if (msg.type === 'chat') {
        div.textContent = `[${msg.username}]: ${msg.text}`;
      } else if (msg.type === 'error') {
        div.style.color = 'red';
        div.textContent = `Error: ${msg.text}`;
      }
      messages.appendChild(div);
      messages.scrollTop = messages.scrollHeight;
    };

    function joinRoom() {
      ws.send(JSON.stringify({
        type: 'join',
        username: document.getElementById('username').value || 'Anonymous',
        room: document.getElementById('room').value || 'general'
      }));
    }

    function sendMsg() {
      const input = document.getElementById('input');
      ws.send(JSON.stringify({ type: 'chat', text: input.value }));
      input.value = '';
    }
  </script>
</body>
</html>

Running and Testing

Start the server and open index.html in multiple browser tabs:

node server.js
# Open index.html in 2+ browser tabs
# Enter different usernames, join the same room, and chat!

You can also test from the terminal using wscat:

npx wscat -c ws://localhost:8080
# Then type: {"type":"join","username":"terminal-user","room":"general"}
# And: {"type":"chat","text":"Hello from the terminal!"}

Production Considerations

Before deploying this to production, address these critical areas:

1. Use a Reverse Proxy

Run behind Nginx or Caddy to handle TLS termination and upgrade HTTP to WebSocket:

# Nginx config snippet
location /ws {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 86400;
}

2. Rate Limiting

Prevent spam by tracking message rates per client:

function handleChat(ws, msg) {
  const now = Date.now();
  ws.msgTimestamps = (ws.msgTimestamps || []).filter(t => now - t < 10000);
  if (ws.msgTimestamps.length >= 10) {
    ws.send(JSON.stringify({ type: 'error', text: 'Slow down! Max 10 msgs/10s' }));
    return;
  }
  ws.msgTimestamps.push(now);
  // ... rest of handler
}

3. Scaling with Redis

For multi-server deployments, use Redis Pub/Sub to share messages across Node.js instances. Libraries like @socket.io/redis-adapter make this straightforward, or you can implement it directly with ioredis.

4. Authentication

Validate JWTs during the WebSocket upgrade handshake rather than after connection:

wss.on('headers', (headers, req) => {
  const token = new URL(req.url, 'http://localhost').searchParams.get('token');
  if (!verifyJWT(token)) req.destroy();
});

Key Takeaways

  • WebSockets provide true bidirectional communication with minimal overhead
  • The ws library is all you need — no heavy frameworks required
  • Heartbeats (ping/pong) are essential to detect dead connections
  • Always implement rate limiting and input validation in production
  • Use Redis Pub/Sub when scaling horizontally across multiple servers
  • Handle TLS termination at the reverse proxy level, not in Node.js

WebSockets remain a fundamental tool in every backend developer’s toolkit. With just Node.js and the ws library, you can build powerful real-time features — from chat apps to live notifications to collaborative editing. The patterns shown here scale from weekend projects to production systems serving thousands of concurrent users.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Privacy Policy · Contact · Sitemap

© 7Tech – Programming and Tech Tutorials