Contents

How to Build a Real-Time Chat with WebSockets and Vanilla JavaScript

A WebSocket-based real-time chat needs two pieces: a server that holds persistent connections and broadcasts messages between clients, and a browser client that opens a WebSocket connection, sends messages on form submit, and renders incoming messages in the DOM. Using Node.js with the ws library and vanilla JavaScript on the client - no React, no Socket.IO, no build step - the complete implementation is under 150 lines of code and handles dozens of simultaneous connections on commodity hardware. This tutorial walks through the entire build.

WebSockets vs. HTTP: Why Persistent Connections Matter for Chat

Before writing any code, it helps to understand why HTTP falls short for chat and what WebSockets actually do differently.

HTTP follows a request-response model. Every interaction requires the client to initiate a request, and the server can never push data spontaneously. If you want to check for new messages, you have to ask for them. A naive chat implementation using setInterval(() => fetch('/messages'), 1000) technically works, but it creates a request per second per connected client. At 100 users polling every second, that’s 100 requests per second of pure overhead, most of which return nothing new. This does not scale.

Long polling improves on this by having the server hold the HTTP request open until new data is available, then send the response. The client immediately opens a new request. It’s better than short polling but still involves full HTTP overhead for every message and requires complex server-side connection management.

A WebSocket connection starts as a normal HTTP request with an Upgrade: websocket header. The server responds with HTTP 101 Switching Protocols. From that point, the TCP connection stays open and both sides can send messages in either direction at any time. The framing overhead is minimal - a 2-byte header for most messages compared to 500+ bytes for a typical HTTP request with headers and cookies.

Server-Sent Events (SSE) are another option worth considering. SSE provides server-to-client streaming over HTTP, but it’s one-directional. The client would need to send messages via separate HTTP POST requests. For chat, where data flows continuously in both directions, WebSockets are the simpler and more natural fit. SSE is better suited for broadcast-only use cases like stock tickers or live dashboards where clients only consume data.

When should you stick with HTTP? For standard request-response patterns where the server response is complete and final - API calls, form submissions, page loads - HTTP is simpler and better supported by caches and CDNs. WebSockets make sense when data flows continuously in both directions and low latency matters.

Building the WebSocket Server in Node.js

The server acts as the central broadcast hub. It maintains a list of active connections and routes messages between them. The following implementation is minimal but production-aware, built on the ws library.

Start with project setup:

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

Node.js 22 LTS has a native WebSocket client built in, but not a server. The ws package provides WebSocketServer for the server side with solid stability and performance - it handles thousands of concurrent connections without breaking a sweat.

The minimal server looks like this:

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

const wss = new WebSocketServer({ port: 8080 });
const clients = new Map();

function escapeHtml(text) {
  return text
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

function broadcast(data) {
  const message = JSON.stringify(data);
  wss.clients.forEach((client) => {
    if (client.readyState === 1) {
      client.send(message);
    }
  });
}

wss.on('connection', (ws) => {
  ws.on('message', (raw) => {
    let data;
    try {
      data = JSON.parse(raw.toString());
    } catch {
      return;
    }

    if (data.type === 'join' && data.username) {
      const username = escapeHtml(data.username.slice(0, 30));
      clients.set(ws, username);
      broadcast({
        type: 'system',
        text: `${username} joined the chat`,
        timestamp: Date.now(),
      });
      return;
    }

    if (data.type === 'message' && data.text) {
      const username = clients.get(ws) || 'Anonymous';
      broadcast({
        type: 'message',
        username,
        text: escapeHtml(data.text.slice(0, 500)),
        timestamp: Date.now(),
      });
    }
  });

  ws.on('close', () => {
    const username = clients.get(ws);
    clients.delete(ws);
    if (username) {
      broadcast({
        type: 'system',
        text: `${username} left the chat`,
        timestamp: Date.now(),
      });
    }
  });

  ws.on('error', (err) => {
    console.error('Socket error:', err.message);
  });
});

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

A few design decisions in this code are worth explaining.

All messages are parsed as JSON and validated before being relayed. The server expects at minimum a type field and either a username (for join) or text (for messages). Everything else gets ignored.

The escapeHtml function sanitizes <, >, &, ", and ' in message text before broadcasting. This is a belt-and-suspenders approach - the client should also use safe DOM methods (more on that shortly), but sanitizing on the server means no client can inject malicious content regardless of how other clients render it.

A Map<WebSocket, string> maps each socket to a username for connection tracking. When a client sends a join message, the server stores the association. On close, it removes the entry and broadcasts a “user left” notification.

The error event handler on each socket prevents unhandled exceptions from crashing the process. Without it, a malformed close frame from a client could take down the entire server.

One thing missing from this minimal version is heartbeat detection. In production, add a ping/pong interval to detect zombie connections - clients that dropped without sending a close frame:

const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
  // ... rest of connection handler
});

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

This pings every connected client every 30 seconds. If a client doesn’t respond with a pong before the next ping cycle, it gets terminated.

The Vanilla JavaScript Client

The browser client connects to the WebSocket server, receives messages, and renders them in the DOM - all in a single HTML file with no framework and no build step.

The complete client HTML:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>WebSocket Chat</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="chat-container">
    <div class="chat-header">
      <h2>Chat Room</h2>
      <div id="status" class="status disconnected">Disconnected</div>
    </div>
    <div id="chat-log" class="chat-log"></div>
    <form id="chat-form" class="chat-form">
      <input type="text" id="message-input"
        placeholder="Type a message..." autocomplete="off" />
      <button type="submit">Send</button>
    </form>
  </div>
  <script src="client.js"></script>
</body>
</html>

And the JavaScript in client.js:

const chatLog = document.getElementById('chat-log');
const chatForm = document.getElementById('chat-form');
const messageInput = document.getElementById('message-input');
const statusEl = document.getElementById('status');

const username = prompt('Enter your username:') || 'Anonymous';
let ws;
let reconnectDelay = 1000;

function connectWebSocket() {
  ws = new WebSocket('ws://localhost:8080');

  ws.onopen = () => {
    statusEl.textContent = 'Connected';
    statusEl.className = 'status connected';
    messageInput.disabled = false;
    reconnectDelay = 1000;
    ws.send(JSON.stringify({ type: 'join', username }));
  };

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

  ws.onclose = () => {
    statusEl.textContent = 'Disconnected - reconnecting...';
    statusEl.className = 'status disconnected';
    messageInput.disabled = true;
    setTimeout(() => connectWebSocket(), reconnectDelay);
    reconnectDelay = Math.min(reconnectDelay * 2, 30000);
  };

  ws.onerror = (err) => {
    console.error('WebSocket error:', err);
  };
}

function appendMessage(data) {
  const div = document.createElement('div');

  if (data.type === 'system') {
    div.className = 'message system';
    div.textContent = data.text;
  } else {
    const isOwn = data.username === username;
    div.className = `message ${isOwn ? 'own' : 'other'}`;

    const nameSpan = document.createElement('span');
    nameSpan.className = 'username';
    nameSpan.textContent = data.username;

    const textSpan = document.createElement('span');
    textSpan.className = 'text';
    textSpan.textContent = data.text;

    div.appendChild(nameSpan);
    div.appendChild(textSpan);
  }

  chatLog.appendChild(div);
  chatLog.scrollTop = chatLog.scrollHeight;
}

chatForm.addEventListener('submit', (e) => {
  e.preventDefault();
  const text = messageInput.value.trim();
  if (!text || ws.readyState !== WebSocket.OPEN) return;
  ws.send(JSON.stringify({ type: 'message', text }));
  messageInput.value = '';
});

connectWebSocket();

A few details in this code deserve attention.

The appendMessage function uses textContent exclusively to set message content. This is the single most important security decision in the client. If you used innerHTML instead, any message containing <script>alert('xss')</script> or an event handler attribute like <img onerror="..."> would execute in every connected browser. textContent treats the string as literal text and never parses it as HTML.

When the connection drops, the onclose handler schedules a reconnection attempt with setTimeout. The delay doubles on each failure, capping at 30 seconds. On a successful connection, it resets to 1 second. This exponential backoff prevents hundreds of clients from hammering a recovering server with immediate reconnect attempts all at once.

The form submit handler checks ws.readyState === WebSocket.OPEN before calling ws.send(). Without this guard, sending on a closed or closing socket throws an exception. The error event on a WebSocket is always followed by close, so reconnection logic only needs to live in the close handler.

Styling the Chat UI with Pure CSS

A functional chat interface needs visual hierarchy, message bubbles that distinguish your messages from others, and a fixed-bottom input. The following CSS handles all of that:

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: #f5f5f5;
  height: 100vh;
}

.chat-container {
  display: grid;
  grid-template-rows: auto 1fr auto;
  height: 100vh;
  max-width: 700px;
  margin: 0 auto;
  background: #fff;
}

.chat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid #eee;
}

.status.connected { color: #22c55e; }
.status.disconnected { color: #ef4444; }

.chat-log {
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  scroll-behavior: smooth;
}

.message {
  max-width: 70%;
  padding: 8px 14px;
  border-radius: 18px;
  word-wrap: break-word;
}

.message.own {
  margin-left: auto;
  background: #0066ff;
  color: #fff;
  border-radius: 18px 18px 4px 18px;
}

.message.other {
  background: #f0f0f0;
  color: #333;
  border-radius: 18px 18px 18px 4px;
}

.message .username {
  display: block;
  font-size: 0.75em;
  font-weight: 600;
  margin-bottom: 2px;
  opacity: 0.8;
}

.message.system {
  color: #999;
  font-size: 0.8em;
  text-align: center;
  max-width: 100%;
  background: none;
  padding: 4px 0;
}

.chat-form {
  display: flex;
  padding: 12px;
  gap: 8px;
  border-top: 1px solid #eee;
  position: sticky;
  bottom: 0;
  background: #fff;
}

.chat-form input {
  flex: 1;
  padding: 10px 14px;
  border: 1px solid #ddd;
  border-radius: 20px;
  outline: none;
  font-size: 1em;
}

.chat-form input:disabled {
  background: #f0f0f0;
  cursor: not-allowed;
}

.chat-form button {
  padding: 10px 20px;
  background: #0066ff;
  color: #fff;
  border: none;
  border-radius: 20px;
  cursor: pointer;
  font-size: 1em;
}

@media (prefers-color-scheme: dark) {
  body { background: #1a1a1a; }
  .chat-container { background: #2a2a2a; }
  .chat-header { border-color: #444; }
  .message.other { background: #3a3a3a; color: #eee; }
  .chat-form { background: #2a2a2a; border-color: #444; }
  .chat-form input {
    background: #3a3a3a;
    border-color: #555;
    color: #eee;
  }
}

The layout uses CSS Grid with grid-template-rows: auto 1fr auto on the container. The header and input form take their natural height, and the message log fills whatever space remains. The log scrolls independently with overflow-y: auto, and scroll-behavior: smooth provides a polished auto-scroll when new messages arrive.

Message bubbles use max-width: 70% to prevent them from spanning the full width on widescreen monitors. Your own messages get margin-left: auto to push them to the right side, mimicking the layout users expect from iMessage, WhatsApp, and every other chat app.

System messages (join/leave) are center-aligned with a smaller, muted font and no bubble styling, making them visually distinct from user content.

The dark mode override using @media (prefers-color-scheme: dark) adjusts background colors, bubble colors, and text colors. Using CSS custom properties (--bg-color, --msg-color) would make this even cleaner, but the direct override approach shown here keeps things simple for a tutorial.

Production Considerations: Security, Scaling, and Deployment

The implementation above demonstrates the fundamentals, but deploying a WebSocket server in production requires addressing authentication, scaling, and encryption.

Authentication

HTTP-level authentication (like a JWT in the Authorization header) doesn’t work during the WebSocket handshake in browsers - the browser’s WebSocket constructor doesn’t support custom headers. Instead, pass a short-lived token as a URL query parameter:

// Client
const ws = new WebSocket(`wss://chat.example.com/?token=${authToken}`);

// Server - verify during the upgrade event
const server = require('http').createServer();
const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', (request, socket, head) => {
  const token = new URL(request.url, 'http://localhost').searchParams.get('token');
  if (!verifyToken(token)) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }
  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit('connection', ws, request);
  });
});

Verify the token during the upgrade event before the WebSocket is fully established. This rejects unauthenticated connections before they can send any messages.

Rate Limiting

Without rate limiting, a single client can spam thousands of messages per second. A simple token bucket approach works well:

const rateLimits = new Map();

function checkRateLimit(ws) {
  const now = Date.now();
  let bucket = rateLimits.get(ws) || { tokens: 10, lastRefill: now };
  const elapsed = (now - bucket.lastRefill) / 1000;
  bucket.tokens = Math.min(10, bucket.tokens + elapsed * 2);
  bucket.lastRefill = now;
  if (bucket.tokens < 1) return false;
  bucket.tokens -= 1;
  rateLimits.set(ws, bucket);
  return true;
}

This allows a burst of 10 messages with a sustained rate of 2 per second. If a client exceeds the limit, drop the message silently or disconnect them on repeated violations.

Scaling Across Multiple Processes

A single Node.js WebSocket server handles 10,000+ concurrent connections comfortably, but it’s limited to one CPU core. For multi-core scaling, use Redis Pub/Sub as a message broker. Each server process subscribes to a Redis channel. When any process receives a message from a client, it publishes to Redis. All processes receive the published message and relay it to their local connected clients. This pattern lets you run one WebSocket process per CPU core behind a load balancer.

TLS and Reverse Proxy

In production, serve WebSocket connections behind Nginx or Caddy with TLS termination. The browser connects via wss:// (encrypted), and Nginx proxies to plain ws:// on the internal network:

location /ws {
    proxy_pass http://localhost: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;
}

The proxy_read_timeout is important - without it, Nginx’s default 60-second timeout will close idle WebSocket connections. Set it to something large (86400 seconds = 24 hours) or implement the heartbeat mechanism described earlier to keep connections alive.

Bun as a Faster Alternative

Bun 1.2 includes a built-in WebSocket server via Bun.serve() with an API similar to Cloudflare Workers. Benchmarks show Bun handling 3-5x more messages per second than Node.js with ws at equivalent concurrency levels. The API is slightly different but the concepts are identical:

Bun.serve({
  port: 8080,
  fetch(req, server) {
    if (server.upgrade(req)) return;
    return new Response('Not found', { status: 404 });
  },
  websocket: {
    open(ws) { /* connection opened */ },
    message(ws, message) { /* handle message */ },
    close(ws) { /* connection closed */ },
  },
});

If you’re starting a new project and don’t need the Node.js ecosystem, Bun is worth considering for WebSocket-heavy workloads.

Serverless WebSockets with Cloudflare Durable Objects

For applications that need global edge deployment with no persistent server, Cloudflare Durable Objects maintain a single-threaded WebSocket hub per “room.” Each Durable Object instance handles the connection state for one chat room, and Cloudflare routes clients to the correct instance automatically. It scales globally and costs roughly $0.15 per million WebSocket messages - a viable option for applications where running your own servers isn’t practical.

Wrapping Up

The entire implementation - server, client, and styles - fits in three small files with no build step and no dependencies beyond the ws package. The WebSocket protocol handles the hard part of maintaining bidirectional connections, and vanilla JavaScript is more than capable of building a responsive chat UI.

For a production deployment, add authentication during the upgrade handshake, rate limiting in the message handler, heartbeat pings to detect dead connections, and TLS termination via a reverse proxy. If you outgrow a single process, Redis Pub/Sub bridges multiple server instances. None of these additions fundamentally change the architecture - they layer on top of the same broadcast pattern shown here.

The full source code for this tutorial can be dropped into any directory and run with node server.js. Open multiple browser tabs pointing to index.html and you have a working multi-user chat room in under a minute.