Skip to content
intermediate Phase 12 · Advanced Backend Patterns

WebSockets & Real-Time

Build real-time features with WebSockets and Socket.io — chat, notifications, and live updates.

1h
0 problems
Topic Progress 0%

WebSocket Protocol Fundamentals

What are WebSockets?

WebSockets provide full-duplex communication over a single TCP connection. Unlike HTTP's request-response model where the client must always initiate communication, WebSockets allow either party to send messages at any time with minimal overhead (2 bytes of framing vs ~800 bytes for an HTTP header).

The WebSocket Handshake

A WebSocket connection starts as an HTTP request and upgrades to the WebSocket protocol:

// Raw WebSocket server using the 'ws' library
import { WebSocketServer } from 'ws';
import http from 'http';

const server = http.createServer();
const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
  console.log('Client connected from:', req.socket.remoteAddress);
  
  // Send a welcome message immediately
  ws.send(JSON.stringify({ type: 'connected', message: 'Welcome!' }));
  
  // Handle incoming messages
  ws.on('message', (data) => {
    const message = JSON.parse(data.toString());
    console.log('Received:', message);
    // Echo back with timestamp
    ws.send(JSON.stringify({ ...message, timestamp: Date.now() }));
  });
  
  ws.on('close', (code, reason) => {
    console.log(`Disconnected: ${code} - ${reason || 'No reason'}`);
  });
  
  ws.on('error', (err) => {
    console.error('WebSocket error:', err.message);
  });
});

server.listen(8080, () => console.log('WebSocket server on port 8080'));

Why Not Just Use HTTP Polling?

Approach Latency Overhead Direction Use Case
Short polling High (100ms+) High (full HTTP per request) Client→Server Simple fallback
Long polling Medium (50ms+) Medium (held connections) Client→Server Legacy browsers
Server-Sent Events Low (10ms+) Low (text only) Server→Client only Live feeds, notifications
WebSockets Very low (<10ms) Very low (2-byte frame) Bidirectional Chat, gaming, collaboration

Frame Structure

WebSocket data is transmitted in frames. Each frame has a header (2–14 bytes) followed by the payload:

  • Text frames: UTF-8 encoded strings (JSON payloads)
  • Binary frames: Raw binary data (file transfers, protobuf)
  • Ping/Pong frames: Built-in keepalive mechanism
  • Close frames: Graceful connection termination with status code

The minimal framing overhead makes WebSockets ideal for high-frequency, low-latency communication where HTTP headers would waste bandwidth.

Socket.io for Production

Socket.io: WebSockets Made Practical

Socket.io is a library built on top of WebSockets that adds automatic reconnection, room/namespace support, fallback to HTTP long-polling, and binary transport. It handles the edge cases that raw WebSocket implementations require significant effort to cover.

Server Setup with Express

import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';

const app = express();
const httpServer = createServer(app);

// Configure Socket.io with CORS and transport options
const io = new Server(httpServer, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST'],
    credentials: true,
  },
  transports: ['websocket', 'polling'], // prefer websocket, fallback to polling
  pingInterval: 25000,  // send ping every 25s
  pingTimeout: 10000,   // disconnect if no pong in 10s
  maxHttpBufferSize: 1e6, // 1MB max message size
});

// Middleware: authenticate before connection
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  if (!token) return next(new Error('Authentication required'));
  
  try {
    const user = verifyToken(token);
    socket.user = user; // attach user to socket for later use
    next();
  } catch (err) {
    next(new Error('Invalid token'));
  }
});

io.on('connection', (socket) => {
  console.log(`User ${socket.user.name} connected (socket: ${socket.id})`);
  
  socket.on('chat:message', async (data) => {
    // Validate and persist the message
    const message = await Message.create({
      userId: socket.user.id,
      room: data.room,
      content: data.content,
    });
    
    // Broadcast to all users in the room (including sender)
    io.to(data.room).emit('chat:message', {
      id: message.id,
      user: socket.user.name,
      content: data.content,
      timestamp: message.createdAt,
    });
  });
  
  socket.on('disconnect', (reason) => {
    console.log(`User ${socket.user.name} disconnected: ${reason}`);
  });
});

httpServer.listen(3001);

Client Implementation

<script src="/socket.io/socket.io.js"></script>
<script>
  const socket = io('http://localhost:3001', {
    auth: { token: localStorage.getItem('authToken') },
    transports: ['websocket', 'polling'],
    reconnectionAttempts: 10,
    reconnectionDelay: 1000,
    reconnectionDelayMax: 5000,
    timeout: 10000,
  });

  socket.on('connect', () => {
    console.log('Connected:', socket.id);
  });

  socket.on('chat:message', (msg) => {
    appendMessage(msg.user, msg.content, msg.timestamp);
  });

  socket.on('connect_error', (err) => {
    console.error('Connection failed:', err.message);
  });

  function sendMessage(content, room) {
    socket.emit('chat:message', { room, content });
  }
</script>

Socket.io handles binary data automatically, multiplexes namespaces on a single connection, and provides transport fallback — making it the default choice for production real-time features.

Rooms and Broadcast Patterns

Rooms, Namespaces, and Broadcasting

Rooms are Socket.io's mechanism for grouping sockets. Every socket can join or leave rooms dynamically, and messages can be broadcast to an entire room, to all connected clients, or to specific sockets — enabling patterns like chat channels, live dashboards, and multiplayer gaming.

Room-Based Chat System

// Server-side: room management
io.on('connection', (socket) => {
  // Join a specific room
  socket.on('join:room', async (roomId) => {
    const room = await Room.findById(roomId);
    if (!room) return socket.emit('error', 'Room not found');
    
    // Leave all previous rooms (except the socket's personal room)
    socket.rooms.forEach(room => {
      if (room !== socket.id) socket.leave(room);
    });
    
    socket.join(roomId);
    
    // Notify others in the room
    socket.to(roomId).emit('user:joined', {
      userId: socket.user.id,
      name: socket.user.name,
      onlineCount: (await io.in(roomId).fetchSockets()).length,
    });
    
    // Send room history to the joining user
    const history = await Message.find({ room: roomId })
      .sort({ createdAt: -1 })
      .limit(50)
      .lean();
    socket.emit('room:history', history.reverse());
  });
  
  // Leave room
  socket.on('leave:room', (roomId) => {
    socket.leave(roomId);
    socket.to(roomId).emit('user:left', {
      userId: socket.user.id,
      name: socket.user.name,
    });
  });
  
  // Handle disconnection — clean up all rooms
  socket.on('disconnect', () => {
    socket.rooms.forEach(room => {
      if (room !== socket.id) {
        io.to(room).emit('user:left', {
          userId: socket.user.id,
          name: socket.user.name,
        });
      }
    });
  });
});

Broadcast Patterns

// 1. Broadcast to ALL connected clients (e.g., system announcements)
io.emit('system:announcement', { message: 'Scheduled maintenance in 10 minutes' });

// 2. Broadcast to everyone EXCEPT the sender
socket.broadcast.emit('user:typing', { userId: socket.user.id });

// 3. Broadcast to a specific room only
io.to('room:general').emit('chat:message', message);

// 4. Broadcast to multiple rooms
const rooms = ['room:engineering', 'room:product'];
rooms.forEach(room => io.to(room).emit('update:deployment', deployInfo));

// 5. Send to specific sockets (direct messaging)
const targetSocket = io.sockets.sockets.get(targetSocketId);
if (targetSocket) {
  targetSocket.emit('dm:new', { from: socket.user.name, content });
}

// 6. Send to all sockets of a specific user (multi-device sync)
io.to(`user:${userId}`).emit('notification:new', notification);
// Each client joins their personal room on connect:
// socket.join(`user:${socket.user.id}`);

Namespaces for Feature Isolation

Namespaces partition a single Socket.io server into logical channels, each with its own middleware, event handlers, and connection lifecycle:

// Chat namespace
const chatNs = io.of('/chat');
chatNs.use(authMiddleware);
chatNs.on('connection', (socket) => {
  // Chat-specific events only
});

// Notifications namespace (separate event handling)
const notifyNs = io.of('/notifications');
notifyNs.use(authMiddleware);
notifyNs.on('connection', (socket) => {
  // Notification-specific events
});

// Client connects to both independently
const chatSocket = io('http://localhost:3001/chat', { auth: { token } });
const notifySocket = io('http://localhost:3001/notifications', { auth: { token } });

Rooms are lightweight and dynamic — unlike namespaces, they don't require separate connections. Use namespaces for major feature boundaries, rooms for grouping within a namespace.

Reconnection and Scaling

Connection Resilience and Horizontal Scaling

Real-time applications face unique challenges: mobile users switch networks, servers restart during deployments, and connections can drop silently. A robust WebSocket system must handle reconnection gracefully and scale horizontally across multiple server instances.

Client-Side Reconnection Strategy

class ResilientSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.options = options;
    this.attempt = 0;
    this.maxAttempts = options.maxAttempts ?? 20;
    this.baseDelay = options.baseDelay ?? 1000;
    this.maxDelay = options.maxDelay ?? 30000;
    this.socket = null;
    this.messageQueue = []; // buffer messages during disconnection
    this.connect();
  }

  connect() {
    this.socket = io(this.url, {
      auth: this.options.auth,
      transports: ['websocket', 'polling'],
      reconnection: false, // we handle reconnection ourselves
    });

    this.socket.on('connect', () => {
      console.log('Connected:', this.socket.id);
      this.attempt = 0;
      this.flushQueue();
    });

    this.socket.on('disconnect', (reason) => {
      console.log('Disconnected:', reason);
      if (reason === 'io server disconnect') {
        // Server kicked us — don't auto-reconnect
        return;
      }
      this.scheduleReconnect();
    });

    this.socket.on('connect_error', (err) => {
      console.error('Connection error:', err.message);
      this.scheduleReconnect();
    });
  }

  scheduleReconnect() {
    if (this.attempt >= this.maxAttempts) {
      console.error('Max reconnection attempts reached');
      return;
    }
    // Exponential backoff with jitter
    const delay = Math.min(
      this.baseDelay * Math.pow(2, this.attempt) + Math.random() * 1000,
      this.maxDelay
    );
    console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.attempt + 1})`);
    setTimeout(() => {
      this.attempt++;
      this.connect();
    }, delay);
  }

  emit(event, data) {
    if (this.socket?.connected) {
      this.socket.emit(event, data);
    } else {
      this.messageQueue.push({ event, data });
    }
  }

  flushQueue() {
    while (this.messageQueue.length > 0) {
      const { event, data } = this.messageQueue.shift();
      this.socket.emit(event, data);
    }
  }
}

Horizontal Scaling with Redis Adapter

When running multiple server instances, a message sent on one instance must reach clients connected to other instances. The Redis adapter uses Redis Pub/Sub to broadcast events across all nodes:

import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

// Attach Redis adapter to Socket.io
io.adapter(createAdapter(pubClient, subClient));

// Now io.to('room:general').emit(...) broadcasts to ALL instances
// Even if the target socket is connected to a different server

Health Monitoring and Graceful Shutdown

// Health check endpoint
app.get('/health', (req, res) => {
  const connections = io.engine.clientsCount;
  const rooms = io.sockets.adapter.rooms.size;
  res.json({ status: 'ok', connections, rooms, uptime: process.uptime() });
});

// Graceful shutdown — notify clients before killing
process.on('SIGTERM', async () => {
  // 1. Stop accepting new connections
  io.emit('server:shutting-down', { message: 'Server restarting, please wait...' });
  
  // 2. Wait for in-flight messages to be sent
  await new Promise(resolve => setTimeout(resolve, 2000));
  
  // 3. Close all connections
  io.close();
  server.close(() => process.exit(0));
});

// Heartbeat monitoring on server
setInterval(() => {
  for (const [id, socket] of io.sockets.sockets) {
    if (!socket.connected) continue;
    socket.emit('ping', { serverTime: Date.now() });
  }
}, 30000);

The combination of client-side exponential backoff with jitter, Redis-backed horizontal scaling, and server-side health monitoring ensures your real-time system remains reliable under failure conditions.

Quiz

1. What is the primary advantage of WebSockets over HTTP long-polling for real-time applications?

Question 1 options

2. When implementing horizontal scaling for Socket.io with multiple server instances, what is the correct approach to ensure messages reach all connected clients?

Question 2 options

3. In a Socket.io chat application, how do you implement direct messaging between two users across different server instances?

Question 3 options

Flashcards

Question

What is the WebSocket handshake and how does it upgrade from HTTP?

Answer

The WebSocket handshake begins as an HTTP GET request with special headers: 'Upgrade: websocket' and 'Connection: Upgrade'. The server responds with 101 Switching Protocols, establishing a persistent TCP connection for full-duplex communication. After the handshake, data flows in WebSocket frames with only 2–14 bytes of overhead, compared to ~800 bytes for HTTP headers per request.

Question

How does Socket.io handle reconnection and what is exponential backoff with jitter?

Answer

Socket.io's reconnection uses exponential backoff with jitter: the delay between attempts doubles each time (1s, 2s, 4s, 8s...) with a random value added (jitter) to prevent all clients from reconnecting simultaneously (thundering herd). The client buffers messages in a queue during disconnection and flushes them once reconnected, ensuring no data loss.

Question

What is the difference between Socket.io rooms and namespaces?

Answer

Rooms are lightweight, dynamic groups that sockets can join/leave at any time — ideal for chat channels, game lobbies, or feature-specific groups within a single connection. Namespaces are major logical partitions that each get their own event handlers and middleware — they require separate client connections. Use rooms for fine-grained grouping, namespaces for isolating entirely different features (chat vs notifications).

Revision Notes

Key Takeaways

  • 1. WebSockets provide full-duplex communication with ~2 bytes of framing overhead vs ~800 bytes for HTTP headers, enabling sub-10ms latency for real-time features.
  • 2. Socket.io adds automatic reconnection, room/namespace support, transport fallback (websocket→polling), and binary data handling on top of raw WebSockets.
  • 3. Rooms enable grouping sockets dynamically for chat channels, live dashboards, and multi-user features. Namespaces partition the server for feature isolation.
  • 4. Horizontal scaling requires the Redis adapter (@socket.io/redis-adapter) to broadcast events across server instances via Redis Pub/Sub.
  • 5. Always implement exponential backoff with jitter on the client, message queuing during disconnection, and graceful server shutdown with client notification.

Interview Tips

  • Explain the WebSocket handshake (HTTP Upgrade → 101 Switching Protocols) and why it's more efficient than HTTP polling for real-time bidirectional communication.
  • Be ready to design a chat system: rooms for channels, personal rooms for DMs, Redis adapter for scaling, and message persistence for history.
  • Know the difference between Socket.io rooms (dynamic, lightweight, same connection) vs namespaces (separate connections, feature isolation).
  • Discuss reconnection strategies: exponential backoff with jitter to prevent thundering herd, message queuing during disconnection, and why sticky sessions are fragile for scaling.
  • Mention production concerns: heartbeat/ping-pong for detecting dead connections, maxHttpBufferSize to prevent abuse, and CORS configuration for cross-origin clients.

Cheat Sheet

WebSocket Protocol: HTTP Upgrade handshake (101 Switching Protocols), frames with 2–14 byte overhead, text/binary/ping/pong/close frames. Socket.io: auto-reconnect, rooms+namespaces, transport fallback (ws→polling), middleware, binary support. Rooms: socket.join(room)/leave(room), io.to(room).emit(), socket.to(room).emit() (excludes sender). Namespaces: io.of('/name') for feature isolation. Scaling: @socket.io/redis-adapter with Redis Pub/Sub for multi-instance broadcast. Reconnection: exponential backoff with jitter, message queue during disconnect, graceful shutdown with client notification. Key metrics: connections, rooms, message throughput, latency.