Advanced25 min read

Real-time with WebSockets

Build real-time features with Socket.io — live chat, notifications, and bidirectional communication.

HTTP vs WebSockets

Everything you have built so far uses HTTP — the request-response protocol that powers the web. The client sends a request, the server processes it, sends back a response, and the connection closes. This works perfectly for most operations: loading a page, submitting a form, fetching data from an API. But what about features that need real-time updates?

Consider a chat application. When Alice sends a message to Bob, Bob needs to see it immediately — not the next time he refreshes the page. With traditional HTTP, Bob's browser has no way of knowing that a new message exists unless it asks the server. This leads to polling — making repeated HTTP requests at regular intervals to check for new data:

javascript
// Polling: ask the server every 2 seconds
setInterval(async () => {
  const res = await fetch('/api/messages?since=' + lastTimestamp);
  const newMessages = await res.json();
  if (newMessages.length > 0) displayMessages(newMessages);
}, 2000);

Polling works, but it is deeply wasteful. If 10,000 users are connected to your chat app, your server handles 5,000 requests per second just for polling — even when nobody is sending messages. Most responses are empty ([]), consuming bandwidth, server CPU, and database connections for nothing. It also introduces latency — a message sent right after a poll will not appear until the next poll interval.

WebSockets solve this fundamentally. Instead of the request-response cycle, WebSockets establish a persistent, bidirectional connection between the client and server. Once the connection is open, either side can send messages at any time without waiting for a request. There is no polling, no wasted requests, and no latency from polling intervals.

The WebSocket protocol starts with an HTTP request called the handshake. The client sends a regular HTTP request with an Upgrade: websocket header. If the server supports WebSockets, it responds with HTTP 101 Switching Protocols, and the connection upgrades from HTTP to the WebSocket protocol (ws:// or wss:// for encrypted). From this point on, the connection stays open and both sides can send frames of data in real time.

Key differences from HTTP:

  • HTTP: client initiates every exchange. Server can only respond, never initiate.
  • WebSocket: either side can send data at any time. Server can push data to clients without being asked.
  • HTTP: new TCP connection for each request (or connection reuse with keep-alive, but still request-response).
  • WebSocket: single TCP connection stays open for the duration of the session.
  • HTTP: headers sent with every request (cookies, auth tokens, content-type). Overhead per request.
  • WebSocket: headers only sent during the initial handshake. Subsequent frames have minimal overhead (2-14 bytes).

WebSockets are ideal for: chat applications, live notifications, collaborative editing (Google Docs), live dashboards (stock tickers, analytics), online gaming, live auctions, and any feature where data changes frequently and users expect instant updates.

Socket.io

While you can use the raw WebSocket API in Node.js (via the ws package), most production applications use Socket.io — a library that provides a higher-level abstraction over WebSockets with critical production features that raw WebSockets lack.

Why Socket.io instead of raw WebSockets?

  1. Automatic fallback — If WebSockets are not available (corporate firewalls, old proxies, certain network configurations block the protocol upgrade), Socket.io falls back to HTTP long-polling seamlessly. Your code does not change — Socket.io handles the transport negotiation automatically.

  2. Auto-reconnection — WebSocket connections drop constantly in the real world: users switch between WiFi and cellular, their laptop goes to sleep, their router restarts. Raw WebSockets just close the connection. Socket.io automatically reconnects with exponential backoff and re-establishes the session.

  3. Rooms — Group connected clients together. All users in a chat room, all players in a game lobby, all members of a team. Sending a message to a room delivers it to every socket in that room.

  4. Namespaces — Separate communication channels on the same connection. Your app might have a /chat namespace for messaging and a /notifications namespace for alerts, each with their own event handlers.

  5. Acknowledgments — Confirm that a message was received. When Alice sends a message, Socket.io can call a callback when the server confirms receipt — essential for "message sent" indicators.

  6. Broadcasting — Send a message to all connected clients except the sender. When Alice sends a message, it should appear in Bob's and Charlie's chat, but not echo back to Alice (she already has it in her UI).

Install Socket.io on the server:

bash
npm install socket.io

Install the client library:

bash
npm install socket.io-client

Socket.io uses an event-driven model. Instead of routes and HTTP methods, you define event names and handlers. The server listens for events from clients (socket.on('chat message', handler)) and emits events to clients (io.emit('chat message', data)). This event-based pattern is more natural for real-time communication than the request-response pattern of REST APIs.

Socket.io is used in production by companies like Microsoft (Teams), Trello, and countless gaming and collaboration platforms. It handles the hard parts of real-time communication — transport fallbacks, reconnection, scaling — so you can focus on building features.

Complete Socket.io Server & Client

javascript
// ══════════════════════════════════════════════════════
// SERVER SIDE (server.js)
// ══════════════════════════════════════════════════════
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);  // Socket.io needs the raw HTTP server
const io = new Server(server, {
  cors: { origin: 'http://localhost:3000' }  // Allow your frontend
});

// Track connected users
const onlineUsers = new Map();

io.on('connection', (socket) => {
  console.log(`User connected: ${socket.id}`);

  // ── Handle user joining ────────────────────────────
  socket.on('user:join', (username) => {
    onlineUsers.set(socket.id, username);
    // Notify ALL clients (including sender) about updated user list
    io.emit('users:online', Array.from(onlineUsers.values()));
    // Notify everyone EXCEPT the sender
    socket.broadcast.emit('user:joined', `${username} joined the chat`);
  });

  // ── Handle chat messages ───────────────────────────
  socket.on('chat:message', (message, callback) => {
    const username = onlineUsers.get(socket.id);
    const msgData = {
      id: Date.now(),
      user: username,
      text: message,
      timestamp: new Date().toISOString()
    };

    // Broadcast to all connected clients
    io.emit('chat:message', msgData);

    // Acknowledgment: confirm message was processed
    if (callback) callback({ status: 'ok', id: msgData.id });
  });

  // ── Handle typing indicator ────────────────────────
  socket.on('chat:typing', () => {
    const username = onlineUsers.get(socket.id);
    socket.broadcast.emit('chat:typing', username);
  });

  // ── Handle disconnection ───────────────────────────
  socket.on('disconnect', () => {
    const username = onlineUsers.get(socket.id);
    onlineUsers.delete(socket.id);
    io.emit('users:online', Array.from(onlineUsers.values()));
    if (username) {
      io.emit('user:left', `${username} left the chat`);
    }
    console.log(`User disconnected: ${socket.id}`);
  });
});

server.listen(5000, () => console.log('Server running on port 5000'));


// ══════════════════════════════════════════════════════
// CLIENT SIDE (React component or vanilla JS)
// ══════════════════════════════════════════════════════
import { io } from 'socket.io-client';

const socket = io('http://localhost:5000');

// Join the chat
socket.emit('user:join', 'Alice');

// Send a message (with acknowledgment)
socket.emit('chat:message', 'Hello everyone!', (ack) => {
  console.log('Server confirmed:', ack); // { status: 'ok', id: 123 }
});

// Listen for incoming messages
socket.on('chat:message', (msg) => {
  console.log(`${msg.user}: ${msg.text}`);
});

// Listen for user list updates
socket.on('users:online', (users) => {
  console.log('Online:', users);
});

// Handle typing indicator
inputField.addEventListener('input', () => {
  socket.emit('chat:typing');
});

Rooms & Namespaces

Real-time applications rarely broadcast everything to everyone. A chat application has multiple rooms. A project management tool has multiple teams. A game server has multiple lobbies. Socket.io provides two mechanisms for organizing connected clients: Rooms and Namespaces.

Rooms are server-side groupings of sockets. A socket can join multiple rooms, and you can emit events to all sockets in a specific room. Rooms are the primary mechanism for scoping real-time communication.

javascript
// Server: join a room
socket.on('room:join', (roomName) => {
  socket.join(roomName);  // Add this socket to the room
  io.to(roomName).emit('system', `${username} joined ${roomName}`);
});

// Server: send message to a specific room
socket.on('room:message', ({ room, message }) => {
  io.to(room).emit('chat:message', { user: username, text: message });
});

// Server: leave a room
socket.on('room:leave', (roomName) => {
  socket.leave(roomName);
  io.to(roomName).emit('system', `${username} left ${roomName}`);
});

Use cases for rooms: chat rooms (each channel is a room), game lobbies (each game session is a room), team notifications (each team is a room), document collaboration (each document is a room). A user in Slack might be in rooms for #general, #engineering, and #random simultaneously, receiving messages from all three.

Emit variations with rooms:

  • io.emit(event, data) — Send to ALL connected sockets everywhere
  • io.to('room1').emit(event, data) — Send to all sockets in room1
  • socket.to('room1').emit(event, data) — Send to all in room1 EXCEPT the sender
  • socket.broadcast.emit(event, data) — Send to all connected sockets EXCEPT the sender

Namespaces are separate communication channels on the same underlying connection. Think of them as independent Socket.io instances that share a physical connection. Each namespace has its own set of event handlers, rooms, and middleware.

javascript
// Server: create namespaces
const chatNs = io.of('/chat');
const adminNs = io.of('/admin');

chatNs.on('connection', (socket) => {
  // Only handles chat events
  socket.on('message', (msg) => chatNs.emit('message', msg));
});

adminNs.on('connection', (socket) => {
  // Only handles admin events — apply admin auth middleware
  socket.on('ban-user', (userId) => { /* admin action */ });
});
javascript
// Client: connect to specific namespace
const chatSocket = io('http://localhost:5000/chat');
const adminSocket = io('http://localhost:5000/admin');

Use namespaces to separate concerns in your application. Regular user events go through the default namespace. Admin events go through /admin with stricter authentication. Real-time analytics go through /analytics. Each namespace can have its own middleware for authentication and authorization, preventing unauthorized access to admin functionality even if a regular user tries to connect to the /admin namespace.

Real-world Use Cases & Patterns

WebSocket-powered real-time features are everywhere in modern applications. Understanding the patterns behind these features will help you design and implement your own.

Live Chat (WhatsApp, Slack, Discord) — The most common use case. Each conversation or channel is a room. Messages are emitted to the room. Typing indicators use a separate event (chat:typing) broadcast to the room, with a debounce on the client side to avoid flooding. Read receipts require acknowledgments — when a client receives a message, it emits a message:read event back. Online presence (green/gray dots) is tracked by maintaining a Set of connected user IDs, broadcast to relevant rooms whenever someone connects or disconnects.

Live Notifications (GitHub, Facebook) — When someone stars your repository or comments on your post, you receive an instant notification without refreshing. The server emits a notification event to the specific user's socket. Each user effectively has their own room (their user ID), and notifications are emitted to that room: io.to('user_' + userId).emit('notification', data).

Collaborative Editing (Google Docs, Figma) — Multiple users editing the same document simultaneously. Each keystroke or canvas change is emitted to the document's room. The challenge is conflict resolution — what happens when two users edit the same paragraph at the same time? Solutions include Operational Transformation (OT) and Conflict-free Replicated Data Types (CRDTs). The WebSocket layer handles the transport; the application layer handles conflict resolution.

Live Dashboards (Stock tickers, Analytics) — A server process monitors data changes (stock prices, website visitor counts, sensor readings) and pushes updates to connected dashboards. The server emits events at regular intervals or when data changes. Clients render updates in real time with charts and graphs. Socket.io's namespace feature works well here — /stocks for market data, /analytics for website metrics, each streaming different data.

Online Gaming (Multiplayer games) — Player positions, actions, and game state are synchronized via WebSockets. Low latency is critical — even 100ms of delay is noticeable in a fast-paced game. Rooms represent game sessions or lobbies. The server is the authority on game state (to prevent cheating), and clients send inputs while receiving the authoritative state. Some games use raw WebSockets (via ws package) instead of Socket.io for the lowest possible overhead.

Common production patterns:

  • Heartbeats — Periodic ping/pong messages to detect dead connections. Socket.io handles this automatically.
  • Message queuing — If a client disconnects temporarily, queue their messages and deliver them when they reconnect.
  • Horizontal scaling — When you have multiple server instances, use the @socket.io/redis-adapter to share events across servers via Redis pub/sub.
  • Authentication — Validate JWT tokens during the Socket.io handshake using middleware: io.use((socket, next) => { verifyToken(socket.handshake.auth.token) ? next() : next(new Error('unauthorized')); });

What is the main advantage of WebSockets over HTTP polling?

Ready to practice?

Create your free account to access the interactive code editor, run challenges, and track your progress.