Node.js

WebSockets

14 Questions

WebSockets provide a persistent, full-duplex communication channel over a single TCP connection, unlike HTTP's stateless request-response model that closes the connection after each exchange. The WebSocket handshake starts as a regular HTTP request with an Upgrade header and is then promoted to the WebSocket protocol, reusing the existing TCP connection for all subsequent communication. This persistent connection eliminates repeated TCP and TLS handshake overhead, making it ideal for real-time features like chat, live feeds, gaming, and collaborative editing.
// HTTP: client sends request → server responds → connection closes
// WebSocket: connection stays open, both sides can send data anytime

// WebSocket handshake starts as HTTP, then upgrades
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade

Why it matters: For scenarios requiring server-initiated data push (real-time scores, stock prices, notifications), the alternatives — HTTP polling or long-polling — waste bandwidth and CPU on repeated connections; WebSockets push data the instant it's available with minimal overhead per message.

Real applications: Financial trading platforms push live price updates; multiplayer games use WebSockets for synchronizing player states at 60 frames/second; collaborative document editors (like Figma or Notion) stream every keystroke and cursor position change in real time.

Common mistakes: Using WebSockets for every client-server interaction regardless of whether real-time bidirectional communication is needed — most endpoints (user profile fetch, form submit) work better as simple HTTP requests; reserve WebSockets for genuinely real-time data streams.

Socket.io is a library that enables real-time, bidirectional communication with automatic fallbacks for older browsers or networks that block WebSocket traffic. It wraps the native WebSocket protocol and adds production-ready features like rooms, namespaces, automatic reconnection, and HTTP long-polling fallback. The setup attaches Socket.io to an existing HTTP server so both web and WebSocket traffic share the same port.
// Server
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

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

  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
  });
});

server.listen(3000);
// Client (HTML)
<script src="/socket.io/socket.io.js"></script>
<script>
  const socket = io();
</script>

Why it matters: Socket.io handles the complexities of running WebSockets in production: automatic reconnection, room-based broadcasting, and transparent fallback to HTTP long-polling when WebSockets are blocked by corporate proxies or firewalls.

Real applications: Chat applications attach Socket.io to the same Express HTTP server on port 3000 so both the REST API and the WebSocket connections coexist; Socket.io automatically serves the client-side bundle at /socket.io/socket.io.js for easy browser inclusion.

Common mistakes: Passing the Express app directly to new Server(app) instead of wrapping it in http.createServer(app) first — Socket.io must attach to a Node.js HTTP server, not an Express application object; skipping this step causes the WebSocket upgrade to fail silently.

Socket.io uses an event-driven model where emit() sends named events and on() listens for them, with any serializable payload including strings, objects, arrays, and binary buffers. Both the server and client can emit and listen to the same custom event names, enabling true bidirectional communication with a simple and consistent API. Socket.io also supports acknowledgment callbacks for request-response patterns where the receiver confirms receipt with a return value.
// Server
io.on('connection', (socket) => {
  // Listen for client event
  socket.on('chat message', (msg) => {
    console.log('Received:', msg);
    // Send back to the sender
    socket.emit('chat message', { text: msg, from: 'server' });
  });

  // Send event with acknowledgment
  socket.emit('welcome', 'Hello!', (response) => {
    console.log('Client acknowledged:', response);
  });
});

// Client
socket.emit('chat message', 'Hi there!');
socket.on('chat message', (data) => {
  console.log(data.text);
});

Why it matters: The event-driven model maps naturally to real-world application events (message sent, user joined, game state changed) and decouples the sender from the receiver; any number of listeners can react to the same event without the emitter needing to know who is listening.

Real applications: Chat apps emit 'chat message' events from clients, which the server broadcasts to a room; collaborative editors emit 'operation' events containing document changes that are applied to all open documents on other clients in the same session.

Common mistakes: Not validating or sanitizing event data received from clients — socket.on('chat message', (msg) => io.emit(msg)) trusts client input directly; always validate event payloads (type, length, content) server-side before processing or broadcasting, just as you would with HTTP request bodies.

Rooms are arbitrary server-side channels that sockets can join and leave at any time, allowing you to broadcast events to a subset of connected clients without the sender needing to manage individual socket IDs. Each socket automatically joins a private room identified by its own socket.id, enabling direct messaging between users. Rooms are commonly used for chat channels, game lobbies, per-document collaboration sessions, and multi-tenant applications.
io.on('connection', (socket) => {
  // Join a room
  socket.on('join room', (roomName) => {
    socket.join(roomName);
    socket.to(roomName).emit('notification', `${socket.id} joined ${roomName}`);
  });

  // Send message to a room
  socket.on('room message', ({ room, message }) => {
    io.to(room).emit('room message', { message, from: socket.id });
  });

  // Leave a room
  socket.on('leave room', (roomName) => {
    socket.leave(roomName);
  });
});

Why it matters: Without rooms, broadcasting to a chat channel would require the server to iterate all connected sockets and filter by some stored metadata; rooms provide this grouping natively and efficiently, regardless of how many total connections exist.

Real applications: Chat channels are separate rooms ('room:general', 'room:engineering'); multiplayer game instances use room IDs as session identifiers; customer support tools put a customer and available agent in a dedicated room for private messaging.

Common mistakes: Using io.to(room).emit() when you want to broadcast to everyone in the room except the emitting socket — this sends the event back to the sender too; use socket.to(room).emit() to exclude the sender, which is the correct pattern for chat message broadcasts.

Namespaces allow you to split the logic of your application over a single shared connection, giving each namespace its own isolated event handlers, rooms, and middleware. Multiple namespaces (e.g., /chat, /admin, /notifications) share the same underlying TCP connection and port, minimizing resource overhead. Namespaces enable applying per-namespace authentication middleware — for example, requiring admin privileges only for the /admin namespace.
// Server — create namespaces
const chatNsp = io.of('/chat');
const adminNsp = io.of('/admin');

chatNsp.on('connection', (socket) => {
  console.log('Chat user connected');
  socket.on('message', (msg) => {
    chatNsp.emit('message', msg);
  });
});

adminNsp.on('connection', (socket) => {
  console.log('Admin connected');
});

// Client — connect to a namespace
const chatSocket  = io('/chat');
const adminSocket = io('/admin');

Why it matters: Without namespaces, a single global event handler handles all connections indiscriminately; namespaces partition the connection logically, so chat traffic, admin commands, and notification subscriptions each have their own isolated event space with appropriate authentication.

Real applications: SaaS dashboards use a /notifications namespace for user alerts, /chat for team messaging, and /admin for operations data; each namespace can have different authentication requirements via nsp.use(middleware) without affecting the others.

Common mistakes: Confusing namespaces and rooms — namespaces are server-defined partitions of the connection space requiring separate client connections; rooms are dynamic server-side groups within a namespace that clients join and leave at runtime; use namespaces for architectural separation and rooms for dynamic grouping.

Broadcasting sends an event to multiple connected sockets simultaneously; Socket.io provides distinct methods depending on whether the sender should be included. Use socket.broadcast.emit() to send to everyone except the emitting socket (e.g., "user X is typing"), and io.emit() to send to all clients including the sender (e.g., global announcements). Room-scoped variants (socket.to(room) and io.in(room)) apply the same sender-inclusion logic within a subset.
io.on('connection', (socket) => {
  // To all clients EXCEPT the sender
  socket.broadcast.emit('user joined', socket.id);

  // To ALL clients INCLUDING the sender
  io.emit('active users', io.engine.clientsCount);

  // To all clients in a room EXCEPT the sender
  socket.to('room1').emit('room event', data);

  // To all clients in a room INCLUDING the sender
  io.in('room1').emit('room event', data);
});

Why it matters: Choosing the wrong broadcast method creates subtle bugs: using io.emit() for a typing indicator causes the typing user to see their own "is typing" notification; using socket.broadcast for a server-generated announcement excludes the triggering client from receiving it.

Real applications: Chat apps use socket.broadcast.emit('user joined', username) so everyone else sees the join notification but the joining user doesn't see it themselves; system announcements (maintenance windows, leaderboard updates) use io.emit() to reach all connected clients.

Common mistakes: Broadcasting sensitive data to all clients in a room when only certain users should see it — always verify authorization before broadcasting to a room; a message in a private conversation should be emitted only to the specific user's personal room (io.to('user:' + userId)), not room-wide.

Socket.io has built-in reconnection logic on the client side with configurable attempts, delays, and exponential backoff that automatically handles transient network interruptions. The client emits distinct events (connect, disconnect, reconnect, reconnect_error) at each stage, providing hooks to update the UI, restore subscriptions, or re-authenticate. On the server, emit missed events on reconnect by implementing session recovery with stored state or a message queue.
// Client-side configuration
const socket = io('http://localhost:3000', {
  reconnection: true,
  reconnectionAttempts: 10,
  reconnectionDelay: 1000,        // Start with 1s
  reconnectionDelayMax: 5000,     // Max 5s between attempts
  timeout: 20000                  // Connection timeout
});

socket.on('connect', ()    => console.log('Connected'));
socket.on('disconnect', (reason) => console.log('Disconnected:', reason));
socket.on('reconnect', (attempt) => console.log('Reconnected after', attempt, 'attempts'));
socket.on('reconnect_error', (err) => console.log('Reconnection error:', err));

Why it matters: Mobile clients routinely lose connectivity when switching networks or entering tunnels; without automatic reconnection, users would need to manually reload the page to re-establish their WebSocket connection after every brief network interruption, causing a poor experience.

Real applications: Chat apps show a "Reconnecting..." banner on disconnect and re-join rooms automatically on the connect event; ticketing systems replay missed events from a Redis stream on reconnect so clients see notifications that arrived while they were offline.

Common mistakes: Not re-joining rooms and re-sending authentication after reconnection — on reconnect, Socket.io establishes a new connection with a new socket ID; any server-side state from the previous connection (room memberships, middleware-set properties like socket.user) must be re-established in the connect event handler.

By default, Socket.io only broadcasts across the single server process where events are emitted because the socket list is stored in memory local to that process. To scale across multiple processes or servers behind a load balancer, use the Redis adapter to synchronize events via Redis pub/sub so broadcasts reach all clients regardless of which server they connected to. Pair the Redis adapter with a load balancer configured for sticky sessions so each client's WebSocket upgrade is routed to the same server.
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

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

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

const io = new Server(server);
io.adapter(createAdapter(pubClient, subClient));

// Now events are broadcast across all servers via Redis pub/sub

Why it matters: Without the Redis adapter, a broadcast from one server instance only reaches clients connected to that instance — on a 3-node cluster, roughly two-thirds of clients miss every broadcast, producing inconsistent behavior that is difficult to debug in production.

Real applications: High-traffic chat platforms run multiple Socket.io nodes behind an Nginx load balancer with ip_hash for sticky sessions; the Redis adapter propagates room broadcasts across all nodes so a message sent from server 1 reaches users connected to servers 2 and 3.

Common mistakes: Forgetting to enable sticky sessions when using the Redis adapter — HTTP long-polling fallback requires multiple requests to the same server to maintain session state; without sticky sessions, polling requests land on different servers and the session is lost, causing repeated reconnection attempts.

Socket.io supports sending binary data (Buffers, ArrayBuffers, Blobs) alongside JSON data without any additional configuration — it detects binary content automatically and handles serialization transparently on both sides. This is useful for real-time file transfers, live image sharing, screen sharing, and audio/video chunk streaming where encoding binary as Base64 string would add 33% overhead. For large files, break them into smaller chunks and emit sequentially to avoid monopolizing the WebSocket connection.
// Server — sending a file as binary
const fs = require('fs');

socket.on('request file', (filename) => {
  const data = fs.readFileSync(filename);
  socket.emit('file data', { name: filename, buffer: data });
});

// Client — receiving binary
socket.on('file data', ({ name, buffer }) => {
  const blob = new Blob([buffer]);
  const url  = URL.createObjectURL(blob);
  console.log('Received file:', name, url);
});

// Client — sending binary
const fileInput = document.getElementById('file');
fileInput.addEventListener('change', (e) => {
  const file = e.target.files[0];
  socket.emit('upload', { name: file.name, data: file });
});

Why it matters: Encoding binary data as Base64 for JSON transmission increases payload size by 33%, adding latency and bandwidth costs; native binary transfer via WebSockets eliminates this overhead and is especially significant for real-time media applications where every millisecond matters.

Real applications: Browser-based image editors send crop/resize results directly as ArrayBuffers; audio streaming applications send PCM audio chunks as Buffers; screen sharing tools decompress and stream JPEG frames as binary payloads rather than base64-encoded strings.

Common mistakes: Sending entire large files (100MB+) in a single emit — this buffers the whole file in memory and blocks the event loop while serializing; instead, stream the file in 64KB chunks using a readable stream, emitting each chunk as a separate event with a sequence number for reassembly.

A real-time chat application combines Socket.io events with rooms and broadcasting to deliver instant messaging: users join a room, send messages that are broadcast to everyone in that room, and receive typing indicators and join/leave notifications. Key server responsibilities include routing messages to the right room, attaching metadata (username, timestamp), and broadcasting system notifications on connect/disconnect. Clients listen for events and update the UI without any page refreshes.
// Server
io.on('connection', (socket) => {
  socket.on('join', (username) => {
    socket.username = username;
    socket.join('general');
    socket.to('general').emit('system', `${username} joined the chat`);
  });

  socket.on('chat message', (msg) => {
    io.to('general').emit('chat message', {
      user: socket.username,
      text: msg,
      time: new Date().toISOString()
    });
  });

  socket.on('typing', () => {
    socket.to('general').emit('typing', socket.username);
  });

  socket.on('disconnect', () => {
    io.to('general').emit('system', `${socket.username} left the chat`);
  });
});

Why it matters: Real-time chat is the canonical use case for WebSockets; understanding how to implement it correctly (rooms, broadcasting, disconnect cleanup, typing indicators) demonstrates mastery of the core Socket.io API patterns that apply to nearly every real-time feature.

Real applications: Customer support tools like Intercom use Socket.io rooms per conversation; collaborative coding environments (like VS Code Live Share) use similar patterns to broadcast cursor positions and document edits; team chat apps persist messages to a database before broadcasting for durability.

Common mistakes: Not cleaning up user state on disconnect — if the server stores usernames in a Map keyed by socket ID but doesn't delete the entry on disconnect, the online user list grows indefinitely; always listen for the disconnect event to clean up all per-socket state.

Socket.io middleware intercepts the connection handshake to verify authentication before any events are exchanged, rejecting unauthenticated connections with a custom error. The client passes a JWT token in the auth object during connection; the server validates it in the middleware with io.use() and attaches the decoded user to the socket. Namespace-level middleware allows different authentication requirements for different namespaces (e.g., admin-only access to /admin).
// Server — authentication middleware
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  if (!token) return next(new Error('Authentication required'));
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    socket.user = decoded;
    next();
  } catch (err) {
    next(new Error('Invalid token'));
  }
});

io.on('connection', (socket) => {
  console.log('Authenticated user:', socket.user.userId);
  socket.join('user:' + socket.user.userId);
});

// Client — send token during connection
const socket = io('http://localhost:3000', {
  auth: { token: localStorage.getItem('jwt') }
});

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

Why it matters: Without authentication middleware, any client can connect to the WebSocket server and join any room or receive any broadcast; a single io.use() middleware validates credentials once at connection time, so all subsequent event handlers on that socket can trust socket.user without repeating auth checks.

Real applications: Private messaging systems authenticate users at connection time and assign each socket to a personal room ('user:' + userId) so direct messages can be sent with io.to('user:123').emit(); admin dashboards authenticate the connection and reject non-admin JWTs before allowing access to the admin namespace.

Common mistakes: Trusting data from socket.handshake.query (URL parameters) instead of socket.handshake.auth for passing tokens — query string tokens are logged in server access logs and browser history; always use the auth object which is sent only in the Socket.io handshake body, not in the URL.

Rate limiting prevents clients from flooding the server with events, protecting against abuse and DoS attacks that don't exist with HTTP because persistent connections enable arbitrary event bursts. Unlike HTTP rate limiting (per IP per route), WebSocket rate limiting must track events per socket per event type using a sliding window or token bucket counter. Clean up rate limit data when sockets disconnect to prevent the tracking Map from growing indefinitely.
function createRateLimiter(maxEvents, windowMs) {
  const clients = new Map();
  
  return (socket, eventName) => {
    const key = socket.id + ':' + eventName;
    const now = Date.now();
    
    if (!clients.has(key)) {
      clients.set(key, []);
    }
    
    const timestamps = clients.get(key).filter(t => now - t < windowMs);
    
    if (timestamps.length >= maxEvents) {
      socket.emit('error', { message: 'Rate limit exceeded' });
      return false;
    }
    
    timestamps.push(now);
    clients.set(key, timestamps);
    return true;
  };
}

const limiter = createRateLimiter(10, 1000); // 10 events per second

io.on('connection', (socket) => {
  socket.on('chat message', (msg) => {
    if (!limiter(socket, 'chat message')) return;
    io.emit('chat message', msg);
  });
});

Why it matters: A malicious client can emit thousands of events per second over a single persistent connection; without per-socket rate limiting, a single bad actor can pin the server's CPU processing their events, degrading performance for all other connected clients simultaneously.

Real applications: Chat apps limit each socket to 10 messages per second; game servers limit position update events to match the game's tick rate (60 per second) and ignore extras; real-time bidding platforms rate-limit bid events to prevent clients from submitting thousands of bids in sub-second intervals.

Common mistakes: Storing rate limit timestamps in a Map that is never cleared after socket disconnect — in a long-running server with thousands of connections over time, the Map grows unboundedly and causes a memory leak; always remove the Map entry in the disconnect event handler.

Presence tracking monitors which users are currently online and broadcasts their status changes to other connected clients in real time, which is essential for chat applications, collaborative tools, and live dashboards. The server maintains a Map of socket ID to user data and broadcasts the updated online user list when clients connect (go online event) or disconnect. For multi-server deployments, replace the in-memory Map with Redis so all server instances share the same presence state.
const onlineUsers = new Map();

io.on('connection', (socket) => {
  socket.on('go online', (userData) => {
    onlineUsers.set(socket.id, {
      userId: userData.userId,
      username: userData.username,
      connectedAt: new Date()
    });
    
    // Broadcast updated user list to all clients
    io.emit('users online', Array.from(onlineUsers.values()));
  });
  
  socket.on('disconnect', () => {
    onlineUsers.delete(socket.id);
    io.emit('users online', Array.from(onlineUsers.values()));
  });
  
  // Send current online users to newly connected client
  socket.emit('users online', Array.from(onlineUsers.values()));
});

// Client
socket.emit('go online', { userId: 1, username: 'Alice' });
socket.on('users online', (users) => {
  console.log('Online:', users.map(u => u.username));
});

Why it matters: Users in collaborative tools need to know who else is editing the same document; customer support teams need to see which agents are available; presence tracking makes the system feel alive and interactive rather than static, and is a core expectation in any real-time application.

Real applications: Google Docs shows colored cursor avatars for all active editors, updating in real time as users join/leave; Slack shows green presence dots that update as users come online or go idle; customer support tools show agent availability status to customers waiting in queue.

Common mistakes: Storing presence by socket ID instead of user ID — if a user opens multiple browser tabs, each tab has a different socket ID; without deduplication by user ID, the same user appears multiple times in the online list and is shown as offline when they close one tab even though other tabs are still connected.

The native ws library provides a lightweight, spec-compliant WebSocket implementation (RFC 6455) without the additional features of Socket.io, giving you raw WebSocket access with minimal overhead. Unlike Socket.io, there is no automatic reconnection, no rooms, no namespaces, and no HTTP long-polling fallback — you implement exactly what you need and nothing more. Choose ws for maximum throughput and minimal memory usage; choose Socket.io when you need its higher-level features out of the box.
// Server with 'ws' library
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
  console.log('Client connected');
  
  ws.on('message', (data) => {
    const message = JSON.parse(data);
    
    // Broadcast to all clients
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message));
      }
    });
  });
  
  ws.on('close', () => console.log('Client disconnected'));
});

// Client (browser native API)
const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (event) => console.log(JSON.parse(event.data));
ws.send(JSON.stringify({ type: 'chat', text: 'Hello' }));

Why it matters: Socket.io adds meaningful overhead — its protocol layer, event parsing, and room tracking use more CPU and memory than raw WebSockets; for high-performance use cases like gaming servers handling 10,000 connections or financial data feeds sending thousands of messages per second, ws is the better choice.

Real applications: Game backend servers use ws directly for sub-millisecond latency; Node.js microservices communicating internally use raw WebSockets; browser-native WebSocket APIs connect to ws servers without needing the Socket.io client bundle, reducing page weight.

Common mistakes: Implementing manual reconnection logic with ws and getting it wrong — the reconnection must handle exponential backoff, jitter, and re-authentication; if you need reliable reconnection out of the box and don't have strict performance requirements, Socket.io's built-in reconnection is likely worth the overhead.