Intermediate
20 min
Full Guide

WebSocket Implementation

Build WebSocket servers and clients with practical code examples

Building a WebSocket Server

Let's build real WebSocket servers and clients from scratch. We'll start with the native Node.js ws library, then explore practical patterns for production applications.

Setting Up the Server

// Install: npm install ws

const WebSocket = require('ws');

// Create WebSocket server on port 8080
const wss = new WebSocket.Server({ port: 8080 });

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

// Handle new connections
wss.on('connection', (ws, request) => {
  console.log('New client connected from:', request.socket.remoteAddress);
  
  // Send welcome message
  ws.send(JSON.stringify({ 
    type: 'welcome', 
    message: 'Connected to server!' 
  }));
  
  // Handle incoming messages
  ws.on('message', (data, isBinary) => {
    if (isBinary) {
      console.log('Received binary data:', data.length, 'bytes');
    } else {
      const message = JSON.parse(data.toString());
      console.log('Received:', message);
      handleMessage(ws, message);
    }
  });
  
  // Handle client disconnect
  ws.on('close', (code, reason) => {
    console.log('Client disconnected:', code, reason.toString());
  });
  
  // Handle errors
  ws.on('error', (error) => {
    console.error('WebSocket error:', error);
  });
});

function handleMessage(ws, message) {
  switch (message.type) {
    case 'chat':
      // Broadcast to all clients
      broadcast({ type: 'chat', user: message.user, text: message.text });
      break;
    case 'ping':
      ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
      break;
  }
}

// Broadcast to all connected clients
function broadcast(message) {
  const data = JSON.stringify(message);
  wss.clients.forEach(client => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(data);
    }
  });
}

Complete Chat Server Example

const WebSocket = require('ws');
const http = require('http');
const { v4: uuidv4 } = require('uuid');

// Create HTTP server (for serving static files or health checks)
const server = http.createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200);
    res.end('OK');
  }
});

// Attach WebSocket to HTTP server
const wss = new WebSocket.Server({ server });

// Store connected clients with metadata
const clients = new Map();

// Store chat rooms
const rooms = new Map();

wss.on('connection', (ws, request) => {
  const clientId = uuidv4();
  const clientInfo = {
    id: clientId,
    ws: ws,
    username: null,
    currentRoom: null,
    joinedAt: new Date()
  };
  clients.set(clientId, clientInfo);
  
  // Send client their ID
  send(ws, { type: 'connected', clientId });
  
  ws.on('message', (data) => {
    try {
      const message = JSON.parse(data.toString());
      handleMessage(clientInfo, message);
    } catch (error) {
      send(ws, { type: 'error', message: 'Invalid message format' });
    }
  });
  
  ws.on('close', () => {
    handleDisconnect(clientInfo);
    clients.delete(clientId);
  });
});

function handleMessage(client, message) {
  switch (message.type) {
    case 'setUsername':
      client.username = message.username;
      send(client.ws, { 
        type: 'usernameSet', 
        username: client.username 
      });
      break;
      
    case 'joinRoom':
      joinRoom(client, message.room);
      break;
      
    case 'leaveRoom':
      leaveRoom(client);
      break;
      
    case 'chatMessage':
      if (client.currentRoom) {
        broadcastToRoom(client.currentRoom, {
          type: 'chatMessage',
          user: client.username,
          text: message.text,
          timestamp: Date.now()
        }, client.id);
      }
      break;
      
    case 'typing':
      if (client.currentRoom) {
        broadcastToRoom(client.currentRoom, {
          type: 'userTyping',
          user: client.username
        }, client.id);
      }
      break;
  }
}

function joinRoom(client, roomName) {
  // Leave current room first
  if (client.currentRoom) {
    leaveRoom(client);
  }
  
  // Create room if doesn't exist
  if (!rooms.has(roomName)) {
    rooms.set(roomName, new Set());
  }
  
  // Join new room
  rooms.get(roomName).add(client.id);
  client.currentRoom = roomName;
  
  // Notify room
  broadcastToRoom(roomName, {
    type: 'userJoined',
    user: client.username,
    room: roomName,
    userCount: rooms.get(roomName).size
  });
  
  // Send room info to client
  send(client.ws, {
    type: 'roomJoined',
    room: roomName,
    userCount: rooms.get(roomName).size
  });
}

function leaveRoom(client) {
  if (!client.currentRoom) return;
  
  const room = rooms.get(client.currentRoom);
  if (room) {
    room.delete(client.id);
    
    broadcastToRoom(client.currentRoom, {
      type: 'userLeft',
      user: client.username,
      userCount: room.size
    });
    
    // Clean up empty rooms
    if (room.size === 0) {
      rooms.delete(client.currentRoom);
    }
  }
  
  client.currentRoom = null;
}

function handleDisconnect(client) {
  if (client.currentRoom) {
    leaveRoom(client);
  }
}

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

function broadcastToRoom(roomName, message, excludeClientId = null) {
  const room = rooms.get(roomName);
  if (!room) return;
  
  const data = JSON.stringify(message);
  room.forEach(clientId => {
    if (clientId !== excludeClientId) {
      const client = clients.get(clientId);
      if (client && client.ws.readyState === WebSocket.OPEN) {
        client.ws.send(data);
      }
    }
  });
}

server.listen(8080, () => {
  console.log('Server running on http://localhost:8080');
});

Browser Client Implementation

// chat-client.js - Robust WebSocket client

class ChatClient {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.clientId = null;
    this.username = null;
    this.currentRoom = null;
    
    // Reconnection settings
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
    this.reconnectDelay = 1000;
    
    // Event handlers (set by application)
    this.onConnected = () => {};
    this.onDisconnected = () => {};
    this.onMessage = () => {};
    this.onError = () => {};
    this.onUserJoined = () => {};
    this.onUserLeft = () => {};
    this.onUserTyping = () => {};
    
    // Message queue for offline sending
    this.messageQueue = [];
  }
  
  connect() {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(this.url);
      
      this.ws.onopen = () => {
        console.log('Connected to server');
        this.reconnectAttempts = 0;
        
        // Flush message queue
        this.flushMessageQueue();
        
        resolve();
      };
      
      this.ws.onmessage = (event) => {
        const message = JSON.parse(event.data);
        this.handleMessage(message);
      };
      
      this.ws.onclose = (event) => {
        console.log('Disconnected:', event.code, event.reason);
        this.onDisconnected(event);
        
        // Attempt reconnection for abnormal closures
        if (event.code !== 1000 && event.code !== 1001) {
          this.attemptReconnect();
        }
      };
      
      this.ws.onerror = (error) => {
        console.error('WebSocket error:', error);
        this.onError(error);
        reject(error);
      };
    });
  }
  
  handleMessage(message) {
    switch (message.type) {
      case 'connected':
        this.clientId = message.clientId;
        this.onConnected(message);
        break;
        
      case 'usernameSet':
        this.username = message.username;
        break;
        
      case 'roomJoined':
        this.currentRoom = message.room;
        break;
        
      case 'chatMessage':
        this.onMessage(message);
        break;
        
      case 'userJoined':
        this.onUserJoined(message);
        break;
        
      case 'userLeft':
        this.onUserLeft(message);
        break;
        
      case 'userTyping':
        this.onUserTyping(message);
        break;
        
      case 'error':
        this.onError(new Error(message.message));
        break;
    }
  }
  
  attemptReconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.log('Max reconnection attempts reached');
      return;
    }
    
    this.reconnectAttempts++;
    const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
    
    console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
    
    setTimeout(() => {
      this.connect().catch(() => {
        // Will retry via onclose handler
      });
    }, delay);
  }
  
  send(message) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
    } else {
      // Queue message for when we reconnect
      this.messageQueue.push(message);
    }
  }
  
  flushMessageQueue() {
    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift();
      this.send(message);
    }
  }
  
  // Public API methods
  setUsername(username) {
    this.send({ type: 'setUsername', username });
  }
  
  joinRoom(room) {
    this.send({ type: 'joinRoom', room });
  }
  
  leaveRoom() {
    this.send({ type: 'leaveRoom' });
    this.currentRoom = null;
  }
  
  sendMessage(text) {
    this.send({ type: 'chatMessage', text });
  }
  
  sendTyping() {
    this.send({ type: 'typing' });
  }
  
  disconnect() {
    if (this.ws) {
      this.ws.close(1000, 'User disconnected');
    }
  }
}

// Usage
const chat = new ChatClient('wss://api.example.com/chat');

chat.onConnected = () => {
  chat.setUsername('John');
  chat.joinRoom('general');
};

chat.onMessage = (message) => {
  displayMessage(message.user, message.text, message.timestamp);
};

chat.onUserJoined = (event) => {
  displaySystemMessage(`${event.user} joined the room`);
};

chat.onUserTyping = (event) => {
  showTypingIndicator(event.user);
};

await chat.connect();

Binary Data Transfer

// Sending binary data (e.g., file upload over WebSocket)

// Client-side: Sending a file
async function sendFile(ws, file) {
  // Read file as ArrayBuffer
  const buffer = await file.arrayBuffer();
  
  // Send metadata first
  ws.send(JSON.stringify({
    type: 'fileStart',
    name: file.name,
    size: file.size,
    mimeType: file.type
  }));
  
  // Send file in chunks
  const CHUNK_SIZE = 64 * 1024; // 64KB chunks
  const chunks = Math.ceil(buffer.byteLength / CHUNK_SIZE);
  
  for (let i = 0; i < chunks; i++) {
    const start = i * CHUNK_SIZE;
    const end = Math.min(start + CHUNK_SIZE, buffer.byteLength);
    const chunk = buffer.slice(start, end);
    
    // Send chunk index as first 4 bytes, then data
    const message = new ArrayBuffer(4 + chunk.byteLength);
    const view = new DataView(message);
    view.setUint32(0, i);
    new Uint8Array(message, 4).set(new Uint8Array(chunk));
    
    ws.send(message);
    
    // Report progress
    console.log(`Sent chunk ${i + 1}/${chunks}`);
  }
  
  ws.send(JSON.stringify({ type: 'fileEnd' }));
}

// Server-side: Receiving file
const fileBuffers = new Map();

ws.on('message', (data, isBinary) => {
  if (isBinary) {
    // Binary chunk received
    const view = new DataView(data.buffer);
    const chunkIndex = view.getUint32(0);
    const chunkData = data.slice(4);
    
    const clientFile = fileBuffers.get(ws);
    if (clientFile) {
      clientFile.chunks.set(chunkIndex, chunkData);
    }
  } else {
    const message = JSON.parse(data.toString());
    
    if (message.type === 'fileStart') {
      fileBuffers.set(ws, {
        name: message.name,
        size: message.size,
        mimeType: message.mimeType,
        chunks: new Map()
      });
    } else if (message.type === 'fileEnd') {
      const fileData = fileBuffers.get(ws);
      if (fileData) {
        // Reassemble file
        const sortedChunks = [...fileData.chunks.entries()]
          .sort((a, b) => a[0] - b[0])
          .map(([_, chunk]) => chunk);
        
        const completeFile = Buffer.concat(sortedChunks);
        saveFile(fileData.name, completeFile);
        fileBuffers.delete(ws);
      }
    }
  }
});

Heartbeat and Connection Monitoring

// Server-side heartbeat implementation
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

// Heartbeat interval
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const CLIENT_TIMEOUT = 35000; // 35 seconds to respond

function heartbeat() {
  this.isAlive = true;
}

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', heartbeat); // Built-in pong handler
  
  // Also handle application-level ping
  ws.on('message', (data) => {
    const message = JSON.parse(data.toString());
    if (message.type === 'ping') {
      ws.send(JSON.stringify({ 
        type: 'pong', 
        timestamp: message.timestamp,
        serverTime: Date.now()
      }));
    }
  });
});

// Check all connections periodically
const pingInterval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      console.log('Terminating dead connection');
      return ws.terminate();
    }
    
    ws.isAlive = false;
    ws.ping(); // Send protocol-level ping
  });
}, HEARTBEAT_INTERVAL);

// Clean up on server close
wss.on('close', () => {
  clearInterval(pingInterval);
});

// Client-side: Monitor connection health
class MonitoredWebSocket {
  constructor(url) {
    this.url = url;
    this.lastPong = Date.now();
    this.connect();
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      this.startMonitoring();
    };
    
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.type === 'pong') {
        this.lastPong = Date.now();
        const latency = this.lastPong - data.timestamp;
        console.log(`Latency: ${latency}ms`);
      }
    };
    
    this.ws.onclose = () => {
      this.stopMonitoring();
    };
  }
  
  startMonitoring() {
    // Send ping every 25 seconds
    this.pingInterval = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ 
          type: 'ping', 
          timestamp: Date.now() 
        }));
      }
    }, 25000);
    
    // Check for missed pongs
    this.checkInterval = setInterval(() => {
      const timeSinceLastPong = Date.now() - this.lastPong;
      if (timeSinceLastPong > 35000) {
        console.log('Connection appears dead, reconnecting...');
        this.ws.close();
        // Reconnection logic here
      }
    }, 5000);
  }
  
  stopMonitoring() {
    clearInterval(this.pingInterval);
    clearInterval(this.checkInterval);
  }
}

Attaching to Express/HTTP Server

const express = require('express');
const http = require('http');
const WebSocket = require('ws');

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

// Regular HTTP routes
app.get('/', (req, res) => {
  res.send('Hello HTTP!');
});

app.get('/api/status', (req, res) => {
  res.json({
    connections: wss.clients.size,
    uptime: process.uptime()
  });
});

// WebSocket handling
wss.on('connection', (ws, req) => {
  // Access HTTP request info
  const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
  const userAgent = req.headers['user-agent'];
  
  console.log(`New WS connection from ${ip}`);
  
  ws.on('message', (data) => {
    // Handle messages...
  });
});

// Optional: Different paths for different WebSocket endpoints
const chatWss = new WebSocket.Server({ noServer: true });
const notifyWss = new WebSocket.Server({ noServer: true });

server.on('upgrade', (request, socket, head) => {
  const pathname = new URL(request.url, 'http://localhost').pathname;
  
  if (pathname === '/ws/chat') {
    chatWss.handleUpgrade(request, socket, head, (ws) => {
      chatWss.emit('connection', ws, request);
    });
  } else if (pathname === '/ws/notifications') {
    notifyWss.handleUpgrade(request, socket, head, (ws) => {
      notifyWss.emit('connection', ws, request);
    });
  } else {
    socket.destroy();
  }
});

server.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

💡 Production Best Practices

  • Always use WSS - Enable TLS for production WebSocket connections
  • Implement heartbeats - Detect dead connections proactively
  • Handle reconnection - Clients should auto-reconnect with backoff
  • Validate messages - Never trust client data, validate everything
  • Set size limits - Prevent memory exhaustion from large messages
  • Log connections - Track connect/disconnect for debugging
  • Use message queuing - Queue messages while reconnecting