DEV Community

Alex Chen
Alex Chen

Posted on

Building a Real-Time Chat App with Node.js and WebSocket

Building a Real-Time Chat App with Node.js and WebSocket

Chat apps seem complex. They're not. Here's a complete working example.

What You'll Build

Browser ←→ WebSocket ←→ Node.js Server ←→ WebSocket ←→ Browser
                    ↕
              In-memory store (or DB)
Enter fullscreen mode Exit fullscreen mode

Step 1: Basic WebSocket Server

// server.js
const { WebSocketServer } = require('ws');
const http = require('http');
const url = require('url');

const server = http.createServer((req, res) => {
  // Serve static files or API
  res.writeHead(200);
  res.end('WebSocket server running');
});

const wss = new WebSocketServer({ server, path: '/ws' });

// Store connections
const clients = new Map(); // ws → { id, name, room }

wss.on('connection', (ws, req) => {
  const params = new url.URL(req.url, `http://${req.headers.host}`).searchParams;
  const roomId = params.get('room') || 'default';
  const userId = `user_${Date.now()}_${Math.random().toString(36).slice(2)}`;

  clients.set(ws, { id: userId, room: roomId });

  console.log(`Client connected: ${userId} in room ${roomId}`);
  console.log(`Total clients: ${clients.size}`);

  // Send welcome message
  sendTo(ws, { type: 'system', message: 'Welcome! Choose a nickname.' });

  // Handle incoming messages
  ws.on('message', (raw) => {
    try {
      const data = JSON.parse(raw.toString());
      handleMessage(ws, data);
    } catch (e) {
      sendError(ws, 'Invalid JSON');
    }
  });

  // Handle disconnect
  ws.on('close', () => {
    const client = clients.get(ws);
    if (client) {
      broadcast(client.room, {
        type: 'leave',
        user: client.id,
        message: `${client.name || 'Someone'} left`,
      });
      clients.delete(ws);
      console.log(`Disconnected: ${client.id}. Remaining: ${clients.size}`);
    }
  });

  // Handle errors
  ws.on('error', (err) => {
    console.error('WebSocket error:', err.message);
  });
});
Enter fullscreen mode Exit fullscreen mode

Step 2: Message Handling

function handleMessage(sender, data) {
  const senderInfo = clients.get(sender);

  switch (data.type) {
    case 'join':
      // User sets their name
      senderInfo.name = data.name || 'Anonymous';
      broadcast(senderInfo.room, {
        type: 'join',
        user: senderInfo.id,
        name: senderInfo.name,
        message: `${senderInfo.name} joined the chat`,
      });
      break;

    case 'message':
      // Regular chat message
      if (!senderInfo.name) {
        return sendError(sender, 'Set your name first with type:"join"');
      }
      broadcast(senderInfo.room, {
        type: 'message',
        user: senderInfo.id,
        name: senderInfo.name,
        text: data.text,
        timestamp: new Date().toISOString(),
      });
      break;

    case 'typing':
      // Typing indicator
      broadcastExcept(sender, senderInfo.room, {
        type: 'typing',
        user: senderInfo.id,
        name: senderInfo.name,
      });
      break;

    case 'private':
      // Direct message to specific user
      sendPrivateMessage(sender, data.targetId, data.text);
      break;

    case 'users':
      // List online users in room
      const users = getRoomUsers(senderInfo.room);
      sendTo(sender, { type: 'users', users });
      break;

    default:
      sendError(sender, `Unknown message type: ${data.type}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Broadcasting

function broadcast(room, message) {
  const payload = JSON.stringify(message);

  for (const [ws, client] of clients) {
    if (client.room === room && ws.readyState === ws.OPEN) {
      ws.send(payload);
    }
  }
}

function broadcastExcept(sender, room, message) {
  const payload = JSON.stringify(message);

  for (const [ws, client] of clients) {
    if (client.room === room && ws !== sender && ws.readyState === ws.OPEN) {
      ws.send(payload);
    }
  }
}

function sendTo(ws, message) {
  if (ws.readyState === ws.OPEN) {
    ws.send(JSON.stringify(message));
  }
}

function sendError(ws, message) {
  sendTo(ws, { type: 'error', message });
}

function getRoomUsers(room) {
  const users = [];
  for (const [ws, client] of clients) {
    if (client.room === room && client.name) {
      users.push({ id: client.id, name: client.name });
    }
  }
  return users;
}

function sendPrivateMessage(sender, targetId, text) {
  const senderInfo = clients.get(sender);

  for (const [ws, client] of clients) {
    if (client.id === targetId) {
      sendTo(ws, {
        type: 'private',
        from: senderInfo.id,
        fromName: senderInfo.name,
        text,
        timestamp: new Date().toISOString(),
      });
      // Also send copy to sender
      sendTo(sender, {
        type: 'private',
        to: targetId,
        text,
        timestamp: new Date().toISOString(),
      });
      return;
    }
  }

  sendError(sender, `User ${targetId} not found`);
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Frontend

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Real-Time Chat</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; height: 100vh; display: flex; flex-direction: column; }
    #header { background: #1e293b; padding: 1rem; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: center; }
    #header h1 { font-size: 1.25rem; font-weight: 600; }
    #online-count { color: #94a3b8; font-size: 0.875rem; }
    #messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
    .msg { max-width: 80%; padding: 0.75rem 1rem; border-radius: 12px; word-wrap: break-word; }
    .msg.system { align-self: center; background: transparent; color: #64748b; font-size: 0.8rem; }
    .msg.incoming { align-self: flex-start; background: #1e293b; border-bottom-left-radius: 4px; }
    .msg.outgoing { align-self: flex-end; background: #3b82f6; color: white; border-bottom-right-radius: 4px; }
    .msg-name { font-weight: 600; font-size: 0.8rem; margin-bottom: 2px; opacity: 0.7; }
    .msg-text { line-height: 1.4; }
    .msg-time { font-size: 0.7rem; margin-top: 4px; opacity: 0.5; }
    #input-area { display: flex; gap: 0.5rem; padding: 1rem; background: #1e293b; border-top: 1px solid #334155; }
    #input { flex: 1; padding: 0.75rem 1rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #e2e8f0; font-size: 1rem; outline: none; }
    #input:focus { border-color: #3b82f6; }
    #send-btn { padding: 0.75rem 1.5rem; background: #3b82f6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; }
    #send-btn:hover { background: #2563eb; }
    #send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
    #typing-indicator { padding: 0 1rem; font-size: 0.8rem; color: #64748b; height: 20px; }
    #name-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 100; }
    #name-modal.hidden { display: none; }
    #name-modal input { padding: 1rem; font-size: 1.125rem; border-radius: 8px; border: 2px solid #3b82f6; width: 300px; outline: none; }
    #name-modal button { margin-top: 1rem; padding: 0.75rem 2rem; background: #3b82f6; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; }
  </style>
</head>
<body>
  <div id="name-modal">
    <div style="text-align:center;">
      <h2>Join Chat</h2>
      <input id="name-input" placeholder="Your name" maxlength="30" />
      <br><button onclick="joinChat()">Join</button>
    </div>
  </div>

  <div id="header">
    <h1>💬 Real-Time Chat</h1>
    <span id="online-count">0 online</span>
  </div>

  <div id="messages"></div>
  <div id="typing-indicator"></div>

  <div id="input-area">
    <input id="input" placeholder="Type a message..." autocomplete="off" />
    <button id="send-btn" onclick="sendMessage()">Send</button>
  </div>

  <script>
    let ws;
    let myName = '';
    let typingTimer;

    function connect() {
      const room = new URLSearchParams(location.search).get('room') || 'default';
      ws = new WebSocket(`ws://${location.host}/ws?room=${room}`);

      ws.onopen = () => console.log('Connected');

      ws.onmessage = (event) => {
        const data = JSON.parse(event.data);
        handleMessage(data);
      };

      ws.onclose = () => {
        addMessage({ type: 'system', message: 'Disconnected. Reconnecting...' });
        setTimeout(connect, 3000);
      };

      ws.onerror = () => console.error('WebSocket error');
    }

    function handleMessage(data) {
      switch (data.type) {
        case 'message':
          addMessage(data);
          break;
        case 'system':
          addMessage(data);
          updateOnlineCount();
          break;
        case 'join':
        case 'leave':
          addMessage(data);
          updateOnlineCount();
          break;
        case 'typing':
          showTyping(data.name);
          break;
        case 'error':
          alert(data.message);
          break;
        case 'users':
          updateOnlineCount(data.users.length);
          break;
      }
    }

    function joinChat() {
      const nameInput = document.getElementById('name-input');
      myName = nameInput.value.trim();
      if (!myName) return nameInput.focus();

      document.getElementById('name-modal').classList.add('hidden');
      connect();

      setTimeout(() => {
        ws.send(JSON.stringify({ type: 'join', name: myName }));
      }, 500);
    }

    function sendMessage() {
      const input = document.getElementById('input');
      const text = input.value.trim();
      if (!text || !ws) return;

      ws.send(JSON.stringify({ type: 'message', text }));
      input.value = '';
      input.focus();
    }

    function addMessage(msg) {
      const container = document.getElementById('messages');
      const div = document.createElement('div');

      div.className = 'msg';
      if (msg.type === 'system') div.classList.add('system');
      else if (msg.name === myName) div.classList.add('outgoing');
      else div.classList.add('incoming');

      div.innerHTML = `
        ${msg.name ? `<div class="msg-name">${escapeHtml(msg.name)}</div>` : ''}
        <div class="msg-text">${escapeHtml(msg.text || msg.message)}</div>
        ${msg.timestamp ? `<div class="msg-time">${new Date(msg.timestamp).toLocaleTimeString()}</div>` : ''}
      `;
      container.appendChild(div);
      container.scrollTop = container.scrollHeight;
    }

    function showTyping(name) {
      const el = document.getElementById('typing-indicator');
      el.textContent = `${name} is typing...`;
      clearTimeout(typingTimer);
      typingTimer = setTimeout(() => el.textContent = '', 2000);
    }

    function updateOnlineCount(count) {
      if (count !== undefined) {
        document.getElementById('online-count').textContent = `${count} online`;
      }
    }

    function escapeHtml(text) {
      const div = document.createElement('div');
      div.textContent = text;
      return div.innerHTML;
    }

    // Enter key sends message
    document.getElementById('input').addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        sendMessage();
      }
    });

    // Allow Enter key in name modal
    document.getElementById('name-input').addEventListener('keydown', (e) => {
      if (e.key === 'Enter') joinChat();
    });
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 5: Run It

# Install dependency
npm install ws

# Start server
node server.js

# Open browser → http://localhost:3000
# Open another tab → http://localhost:3000
# Type messages → they appear in real-time!
Enter fullscreen mode Exit fullscreen mode

Production Considerations

// 1. Authentication (add JWT verification)
import jwt from 'jsonwebtoken';

wss.on('connection', (ws, req) => {
  const token = new URL(req.url, 'http://localhost').searchParams.get('token');

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    clients.set(ws, { id: payload.userId, name: payload.name, ... });
  } catch {
    ws.close(4001, 'Authentication failed');
    return;
  }
});

// 2. Persistence (store messages in database)
async function saveMessage(room, user, text) {
  await db.run(
    'INSERT INTO messages (room, user_id, name, text, created_at) VALUES (?, ?, ?, ?, datetime("now"))',
    [room, user.id, user.name, text]
  );
}

// Send recent history on join
async function sendHistory(ws, room) {
  const messages = await db.all(
    'SELECT * FROM messages WHERE room = ? ORDER BY created_at DESC LIMIT 50',
    [room]
  );
  sendTo(ws, { type: 'history', messages: messages.reverse() });
}

// 3. Rate limiting per connection
const rateLimit = new Map(); // ws → { count, resetTime }

function checkRateLimit(ws) {
  const now = Date.now();
  const limit = rateLimit.get(ws);

  if (!limit || now > limit.resetTime) {
    rateLimit.set(ws, { count: 1, resetTime: now + 60000 }); // 60/min
    return true;
  }

  if (limit.count >= 60) {
    sendError(ws, 'Rate limited. Slow down!');
    return false;
  }

  limit.count++;
  return true;
}

// 4. Scale with Redis Pub/Sub (multiple servers)
const Redis = require('ioredis');
const pub = new Redis(process.env.REDIS_URL);
const sub = new Redis(process.env.REDIS_URL);

sub.subscribe('chat:default', (message) => {
  const data = JSON.parse(message);
  broadcast('default', data); // Forward to local connections
});

// When receiving a message:
pub.publish(`chat:${room}`, JSON.stringify(messageData));
Enter fullscreen mode Exit fullscreen mode

Want to take this further? Add rooms, file sharing, reactions, read receipts...

Follow @armorbreak for more Node.js content.

Top comments (0)