Overview

WebSockets provide full-duplex communication over a single TCP connection. Unlike HTTP request-response, the server can push data to the client at any time. This tutorial explains the protocol, then builds a working chat server and client using Node.js and the ws library.

WebSocket vs HTTP Polling

ApproachLatencyServer loadUse case
Short pollingHigh (interval-bound)High (many empty responses)Legacy systems
Long pollingMediumMediumFallback for older clients
Server-Sent EventsLowLowOne-way server push
WebSocketLowestLowChat, games, live collaboration

How the Handshake Works

A WebSocket connection begins as an HTTP request with an Upgrade header:

GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

The server responds with 101 Switching Protocols, and the connection is upgraded. From that point on, both sides exchange frames in either direction.

Step 1: Initialize the Server

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

Step 2: Create the WebSocket Server

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

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

wss.on('connection', (ws, req) => {
  const ip = req.socket.remoteAddress;
  console.log(`Client connected from ${ip}`);
  clients.add(ws);

  ws.send(JSON.stringify({ type: 'system', message: 'Welcome to the chat' }));

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

    const outbound = JSON.stringify({
      type: 'chat',
      message: payload.message,
      timestamp: Date.now(),
    });

    for (const client of clients) {
      if (client.readyState === ws.OPEN) {
        client.send(outbound);
      }
    }
  });

  ws.on('close', () => {
    clients.delete(ws);
    console.log('Client disconnected');
  });

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

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

Run it:

node server.js

Step 3: Build the Browser Client

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>WebSocket Chat</title>
</head>
<body>
  <ul id="messages"></ul>
  <input id="input" autocomplete="off" />
  <button id="send">Send</button>

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

    socket.addEventListener('open', () => {
      console.log('Connected');
    });

    socket.addEventListener('message', (event) => {
      const data = JSON.parse(event.data);
      const li = document.createElement('li');
      li.textContent = data.type === 'chat'
        ? `${new Date(data.timestamp).toLocaleTimeString()}: ${data.message}`
        : data.message;
      messages.appendChild(li);
    });

    socket.addEventListener('close', () => {
      console.log('Disconnected');
    });

    function send() {
      if (!input.value) return;
      socket.send(JSON.stringify({ message: input.value }));
      input.value = '';
    }

    document.getElementById('send').addEventListener('click', send);
    input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
  </script>
</body>
</html>

Heartbeats and Reconnection

Networks drop idle connections. Production clients should ping the server periodically and reconnect on close.

// Server-side ping
const interval = setInterval(() => {
  for (const ws of wss.clients) {
    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; });
});

wss.on('close', () => clearInterval(interval));
// Client-side reconnection with backoff
let retryDelay = 1000;

function connect() {
  const socket = new WebSocket('ws://localhost:8080');

  socket.addEventListener('open', () => { retryDelay = 1000; });

  socket.addEventListener('close', () => {
    setTimeout(connect, retryDelay);
    retryDelay = Math.min(retryDelay * 2, 30000);
  });
}

Scaling Beyond a Single Server

A single Node.js process holds the set of connected clients in memory. When you scale horizontally, add a message broker so that a message received by one instance reaches clients on another:

BrokerNotes
Redis Pub/SubSimple, low latency, no persistence
NATSLightweight, high throughput
KafkaDurable, replayable, heavier operation

Use socket.io or a managed service if you need sticky sessions, rooms, and fallbacks out of the box.

Security Checklist

  • Always use wss:// in production. Plain ws:// transmits in cleartext.
  • Validate the Origin header during the handshake to prevent cross-site WebSocket hijacking.
  • Authenticate after connection opens, typically by sending a JWT as the first message.
  • Rate-limit messages per client to prevent flooding.
  • Set a maximum message size to avoid memory exhaustion.

Common Pitfalls

ProblemCauseFix
Connection closes after 60 secondsProxy idle timeoutEnable heartbeat pings or raise proxy timeout
Browser throws 403 on upgradeProxy or load balancer strips upgrade headersConfigure Nginx with proxy_set_header Upgrade
Messages lost on reconnectNo delivery guaranteeAdd sequence numbers and replay from a store