Introduction to Real-Time Communication

WebSockets provide full-duplex communication channels over a single TCP connection, enabling real-time data transfer between clients and servers. Unlike HTTP, WebSockets maintain a persistent connection.

HTTP vs WebSockets

// HTTP Request-Response (Half-duplex)
Client → Request → Server
Client ← Response ← Server
// Connection closes after each request

// WebSocket (Full-duplex)
Client ←→ Server
// Persistent bi-directional connection
// Either party can send messages anytime

When to Use WebSockets

// Good use cases for WebSockets:
- Chat applications
- Live notifications
- Real-time collaboration (Google Docs, Figma)
- Live sports/stock tickers
- Multiplayer games
- Live location tracking

// When HTTP is sufficient:
- Static content
- Form submissions
- API calls that don't need real-time updates
- Infrequent data updates

Socket.IO Basics

Server Setup

// Install dependencies
// npm install express socket.io

// server.js
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: {
    origin: "http://localhost:3000",
    methods: ["GET", "POST"],
    credentials: true
  }
});

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

  // Listen for events from client
  socket.on('message', (data) => {
    console.log('Received:', data);

    // Send to sender only
    socket.emit('message-received', { status: 'ok' });

    // Broadcast to all except sender
    socket.broadcast.emit('new-message', data);

    // Send to everyone including sender
    io.emit('message', data);
  });

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

httpServer.listen(4000, () => {
  console.log('Server running on port 4000');
});

Client Setup

// Install: npm install socket.io-client

// src/socket.js
import { io } from 'socket.io-client';

const SOCKET_URL = process.env.REACT_APP_SOCKET_URL || 'http://localhost:4000';

export const socket = io(SOCKET_URL, {
  autoConnect: false, // Manual connection control
  withCredentials: true,
  transports: ['websocket', 'polling'], // Prefer WebSocket
});

// Connection management
export const connectSocket = (token) => {
  socket.auth = { token };
  socket.connect();
};

export const disconnectSocket = () => {
  socket.disconnect();
};

// src/App.jsx
import { useEffect } from 'react';
import { socket, connectSocket, disconnectSocket } from './socket';

function App() {
  useEffect(() => {
    // Connect when component mounts
    connectSocket();

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

    socket.on('disconnect', (reason) => {
      console.log('Disconnected:', reason);
    });

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

    // Cleanup on unmount
    return () => {
      disconnectSocket();
    };
  }, []);

  return <div>{/* App content */}</div>;
}

Building a Chat Application

Server Implementation

// server/chat.js
const users = new Map();
const rooms = new Map();

function setupChatHandlers(io) {
  io.on('connection', (socket) => {
    let currentUser = null;
    let currentRoom = null;

    // User joins chat
    socket.on('join', ({ username, room }) => {
      currentUser = { id: socket.id, username };
      currentRoom = room;

      // Store user
      users.set(socket.id, currentUser);

      // Join room
      socket.join(room);

      // Initialize room if needed
      if (!rooms.has(room)) {
        rooms.set(room, { users: [], messages: [] });
      }
      rooms.get(room).users.push(currentUser);

      // Notify room
      socket.to(room).emit('user-joined', {
        user: currentUser,
        message: `${username} joined the chat`,
      });

      // Send room info to user
      socket.emit('room-info', {
        room,
        users: rooms.get(room).users,
        recentMessages: rooms.get(room).messages.slice(-50),
      });
    });

    // Handle messages
    socket.on('send-message', ({ content, room }) => {
      const message = {
        id: Date.now(),
        content,
        sender: currentUser,
        timestamp: new Date().toISOString(),
        room,
      };

      // Store message
      if (rooms.has(room)) {
        rooms.get(room).messages.push(message);
      }

      // Broadcast to room
      io.to(room).emit('new-message', message);
    });

    // Typing indicator
    socket.on('typing-start', ({ room }) => {
      socket.to(room).emit('user-typing', {
        user: currentUser,
        isTyping: true,
      });
    });

    socket.on('typing-stop', ({ room }) => {
      socket.to(room).emit('user-typing', {
        user: currentUser,
        isTyping: false,
      });
    });

    // Leave room
    socket.on('leave-room', () => {
      if (currentRoom && currentUser) {
        handleLeave();
      }
    });

    // Disconnect
    socket.on('disconnect', () => {
      if (currentRoom && currentUser) {
        handleLeave();
      }
      users.delete(socket.id);
    });

    function handleLeave() {
      socket.leave(currentRoom);

      if (rooms.has(currentRoom)) {
        const room = rooms.get(currentRoom);
        room.users = room.users.filter(u => u.id !== socket.id);

        socket.to(currentRoom).emit('user-left', {
          user: currentUser,
          message: `${currentUser.username} left the chat`,
        });
      }
    }
  });
}

module.exports = { setupChatHandlers };

React Chat Component

// src/components/Chat.jsx
import { useState, useEffect, useRef, useCallback } from 'react';
import { socket } from '../socket';

function Chat({ username, room }) {
  const [messages, setMessages] = useState([]);
  const [inputValue, setInputValue] = useState('');
  const [typingUsers, setTypingUsers] = useState([]);
  const [users, setUsers] = useState([]);
  const messagesEndRef = useRef(null);
  const typingTimeoutRef = useRef(null);

  // Auto-scroll to bottom
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  // Join room on mount
  useEffect(() => {
    socket.emit('join', { username, room });

    return () => {
      socket.emit('leave-room');
    };
  }, [username, room]);

  // Socket event listeners
  useEffect(() => {
    const handleRoomInfo = ({ users, recentMessages }) => {
      setUsers(users);
      setMessages(recentMessages);
    };

    const handleNewMessage = (message) => {
      setMessages(prev => [...prev, message]);
    };

    const handleUserJoined = ({ user }) => {
      setUsers(prev => [...prev, user]);
      setMessages(prev => [...prev, {
        id: Date.now(),
        type: 'system',
        content: `${user.username} joined the chat`,
      }]);
    };

    const handleUserLeft = ({ user }) => {
      setUsers(prev => prev.filter(u => u.id !== user.id));
      setMessages(prev => [...prev, {
        id: Date.now(),
        type: 'system',
        content: `${user.username} left the chat`,
      }]);
    };

    const handleUserTyping = ({ user, isTyping }) => {
      setTypingUsers(prev => {
        if (isTyping) {
          if (!prev.find(u => u.id === user.id)) {
            return [...prev, user];
          }
        } else {
          return prev.filter(u => u.id !== user.id);
        }
        return prev;
      });
    };

    socket.on('room-info', handleRoomInfo);
    socket.on('new-message', handleNewMessage);
    socket.on('user-joined', handleUserJoined);
    socket.on('user-left', handleUserLeft);
    socket.on('user-typing', handleUserTyping);

    return () => {
      socket.off('room-info', handleRoomInfo);
      socket.off('new-message', handleNewMessage);
      socket.off('user-joined', handleUserJoined);
      socket.off('user-left', handleUserLeft);
      socket.off('user-typing', handleUserTyping);
    };
  }, []);

  // Handle typing indicator
  const handleTyping = useCallback(() => {
    socket.emit('typing-start', { room });

    // Clear existing timeout
    if (typingTimeoutRef.current) {
      clearTimeout(typingTimeoutRef.current);
    }

    // Stop typing after 2 seconds of inactivity
    typingTimeoutRef.current = setTimeout(() => {
      socket.emit('typing-stop', { room });
    }, 2000);
  }, [room]);

  // Send message
  const sendMessage = (e) => {
    e.preventDefault();
    if (!inputValue.trim()) return;

    socket.emit('send-message', {
      content: inputValue,
      room,
    });

    socket.emit('typing-stop', { room });
    setInputValue('');
  };

  return (
    <div className="chat-container">
      {/* Users sidebar */}
      <aside className="users-sidebar">
        <h3>Users ({users.length})</h3>
        <ul>
          {users.map(user => (
            <li key={user.id}>
              {user.username}
              {user.id === socket.id && ' (you)'}
            </li>
          ))}
        </ul>
      </aside>

      {/* Messages */}
      <main className="messages-area">
        <div className="messages-list">
          {messages.map(message => (
            <div
              key={message.id}
              className={`message ${
                message.type === 'system' ? 'system' :
                message.sender?.id === socket.id ? 'sent' : 'received'
              }`}
            >
              {message.type !== 'system' && (
                <span className="sender">{message.sender.username}</span>
              )}
              <p>{message.content}</p>
              {message.timestamp && (
                <span className="time">
                  {new Date(message.timestamp).toLocaleTimeString()}
                </span>
              )}
            </div>
          ))}
          <div ref={messagesEndRef} />
        </div>

        {/* Typing indicator */}
        {typingUsers.length > 0 && (
          <div className="typing-indicator">
            {typingUsers.map(u => u.username).join(', ')}{' '}
            {typingUsers.length === 1 ? 'is' : 'are'} typing...
          </div>
        )}

        {/* Input form */}
        <form onSubmit={sendMessage} className="message-form">
          <input
            type="text"
            value={inputValue}
            onChange={(e) => {
              setInputValue(e.target.value);
              handleTyping();
            }}
            placeholder="Type a message..."
          />
          <button type="submit">Send</button>
        </form>
      </main>
    </div>
  );
}

Rooms and Namespaces

Working with Rooms

// Rooms - subdivisions within a namespace
io.on('connection', (socket) => {
  // Join a room
  socket.join('room-123');
  socket.join(['room-A', 'room-B']); // Join multiple

  // Leave a room
  socket.leave('room-123');

  // Emit to a room
  io.to('room-123').emit('event', data);

  // Emit to multiple rooms
  io.to('room-A').to('room-B').emit('event', data);

  // Emit to room except sender
  socket.to('room-123').emit('event', data);

  // Get all sockets in a room
  const sockets = await io.in('room-123').fetchSockets();

  // Get rooms a socket is in
  console.log(socket.rooms); // Set { socket.id, 'room-123' }
});

// Example: Private messaging
socket.on('private-message', async ({ to, content }) => {
  const targetSocket = await io.in(to).fetchSockets();
  if (targetSocket.length) {
    io.to(to).emit('private-message', {
      from: socket.id,
      content,
    });
  }
});

Namespaces

// Namespaces - separate communication channels
const io = new Server(httpServer);

// Default namespace
io.on('connection', (socket) => {
  // handles connections to /
});

// Chat namespace
const chatNamespace = io.of('/chat');
chatNamespace.on('connection', (socket) => {
  console.log('Connected to chat namespace');
  // Chat-specific events
});

// Admin namespace with authentication
const adminNamespace = io.of('/admin');
adminNamespace.use((socket, next) => {
  const token = socket.handshake.auth.token;
  if (verifyAdminToken(token)) {
    next();
  } else {
    next(new Error('Unauthorized'));
  }
});

adminNamespace.on('connection', (socket) => {
  console.log('Admin connected');
  // Admin-only events
});

// Client connecting to namespaces
const chatSocket = io('http://localhost:4000/chat');
const adminSocket = io('http://localhost:4000/admin', {
  auth: { token: adminToken }
});

Authentication with Socket.IO

// Server-side authentication middleware
const jwt = require('jsonwebtoken');

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('User connected:', socket.user.username);

  // User-specific room (for private messages)
  socket.join(`user:${socket.user.id}`);
});

// Client-side
import { io } from 'socket.io-client';

const socket = io('http://localhost:4000', {
  auth: {
    token: localStorage.getItem('token'),
  },
});

// Handle auth errors
socket.on('connect_error', (err) => {
  if (err.message === 'Authentication required') {
    // Redirect to login
    window.location.href = '/login';
  }
});

// Reconnect with new token after refresh
socket.auth = { token: newToken };
socket.connect();

Real-Time Notifications

// server/notifications.js
const notifications = new Map(); // userId -> notifications[]

function setupNotifications(io) {
  io.on('connection', (socket) => {
    const userId = socket.user.id;

    // Send pending notifications
    if (notifications.has(userId)) {
      socket.emit('pending-notifications', notifications.get(userId));
    }

    // Mark as read
    socket.on('notification-read', (notificationId) => {
      const userNotifs = notifications.get(userId) || [];
      const notif = userNotifs.find(n => n.id === notificationId);
      if (notif) {
        notif.read = true;
      }
    });

    // Mark all as read
    socket.on('notifications-read-all', () => {
      const userNotifs = notifications.get(userId) || [];
      userNotifs.forEach(n => n.read = true);
    });
  });
}

// Send notification to user (from anywhere in app)
function sendNotification(io, userId, notification) {
  const notif = {
    id: Date.now(),
    ...notification,
    read: false,
    createdAt: new Date().toISOString(),
  };

  // Store notification
  if (!notifications.has(userId)) {
    notifications.set(userId, []);
  }
  notifications.get(userId).push(notif);

  // Send to user if online
  io.to(`user:${userId}`).emit('notification', notif);
}

// React Notifications Component
function Notifications() {
  const [notifications, setNotifications] = useState([]);
  const [unreadCount, setUnreadCount] = useState(0);
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    socket.on('pending-notifications', (notifs) => {
      setNotifications(notifs);
      setUnreadCount(notifs.filter(n => !n.read).length);
    });

    socket.on('notification', (notif) => {
      setNotifications(prev => [notif, ...prev]);
      setUnreadCount(prev => prev + 1);

      // Show browser notification
      if (Notification.permission === 'granted') {
        new Notification(notif.title, {
          body: notif.message,
          icon: '/notification-icon.png',
        });
      }
    });

    return () => {
      socket.off('pending-notifications');
      socket.off('notification');
    };
  }, []);

  const markAllRead = () => {
    socket.emit('notifications-read-all');
    setNotifications(prev => prev.map(n => ({ ...n, read: true })));
    setUnreadCount(0);
  };

  return (
    <div className="notifications">
      <button onClick={() => setIsOpen(!isOpen)}>
        🔔 {unreadCount > 0 && <span>{unreadCount}</span>}
      </button>

      {isOpen && (
        <div className="notification-dropdown">
          <header>
            <h3>Notifications</h3>
            <button onClick={markAllRead}>Mark all read</button>
          </header>
          <ul>
            {notifications.map(notif => (
              <li key={notif.id} className={notif.read ? '' : 'unread'}>
                <strong>{notif.title}</strong>
                <p>{notif.message}</p>
                <time>{formatTime(notif.createdAt)}</time>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

Real-Time Collaboration

// Collaborative document editing (simplified)
const documents = new Map();

io.on('connection', (socket) => {
  let currentDoc = null;

  socket.on('join-document', async (docId) => {
    currentDoc = docId;
    socket.join(`doc:${docId}`);

    // Initialize document
    if (!documents.has(docId)) {
      const doc = await Document.findById(docId);
      documents.set(docId, {
        content: doc.content,
        cursors: new Map(),
        selections: new Map(),
      });
    }

    const doc = documents.get(docId);
    doc.cursors.set(socket.id, { user: socket.user, position: 0 });

    // Send current state
    socket.emit('document-state', {
      content: doc.content,
      cursors: Array.from(doc.cursors.entries()),
    });

    // Notify others
    socket.to(`doc:${docId}`).emit('user-joined-document', {
      socketId: socket.id,
      user: socket.user,
    });
  });

  // Handle text changes (Operational Transformation simplified)
  socket.on('text-change', ({ docId, delta, version }) => {
    const doc = documents.get(docId);
    if (!doc) return;

    // Apply delta to document
    doc.content = applyDelta(doc.content, delta);

    // Broadcast to others
    socket.to(`doc:${docId}`).emit('text-change', {
      delta,
      author: socket.user,
      version: version + 1,
    });
  });

  // Cursor position updates
  socket.on('cursor-move', ({ docId, position }) => {
    const doc = documents.get(docId);
    if (!doc) return;

    doc.cursors.set(socket.id, { user: socket.user, position });

    socket.to(`doc:${docId}`).emit('cursor-update', {
      socketId: socket.id,
      user: socket.user,
      position,
    });
  });

  // Selection changes
  socket.on('selection-change', ({ docId, selection }) => {
    socket.to(`doc:${docId}`).emit('selection-update', {
      socketId: socket.id,
      user: socket.user,
      selection,
    });
  });

  socket.on('disconnect', () => {
    if (currentDoc) {
      const doc = documents.get(currentDoc);
      if (doc) {
        doc.cursors.delete(socket.id);
        doc.selections.delete(socket.id);
      }
      io.to(`doc:${currentDoc}`).emit('user-left-document', {
        socketId: socket.id,
      });
    }
  });
});

Custom React Hook for Socket.IO

// src/hooks/useSocket.js
import { useEffect, useCallback, useState, useRef } from 'react';
import { socket } from '../socket';

export function useSocket(eventName, callback) {
  const savedCallback = useRef(callback);

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    const handler = (...args) => savedCallback.current(...args);
    socket.on(eventName, handler);
    return () => socket.off(eventName, handler);
  }, [eventName]);
}

export function useSocketEmit() {
  const emit = useCallback((event, data) => {
    return new Promise((resolve, reject) => {
      socket.emit(event, data, (response) => {
        if (response.error) {
          reject(response.error);
        } else {
          resolve(response);
        }
      });
    });
  }, []);

  return emit;
}

export function useSocketRoom(room) {
  const [isJoined, setIsJoined] = useState(false);
  const [users, setUsers] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    socket.emit('join-room', room, (response) => {
      if (response.error) {
        setError(response.error);
      } else {
        setIsJoined(true);
        setUsers(response.users);
      }
    });

    socket.on('room-users-update', setUsers);

    return () => {
      socket.emit('leave-room', room);
      socket.off('room-users-update', setUsers);
    };
  }, [room]);

  return { isJoined, users, error };
}

// Usage
function ChatRoom({ room }) {
  const [messages, setMessages] = useState([]);
  const { isJoined, users, error } = useSocketRoom(room);
  const emit = useSocketEmit();

  useSocket('new-message', (message) => {
    setMessages(prev => [...prev, message]);
  });

  const sendMessage = async (content) => {
    try {
      await emit('send-message', { room, content });
    } catch (err) {
      console.error('Failed to send:', err);
    }
  };

  if (error) return <div>Error: {error}</div>;
  if (!isJoined) return <div>Connecting...</div>;

  return (
    <div>
      <UsersList users={users} />
      <MessagesList messages={messages} />
      <MessageInput onSend={sendMessage} />
    </div>
  );
}

Error Handling & Reconnection

// Server-side error handling
io.on('connection', (socket) => {
  // Wrap handlers with error handling
  const withErrorHandler = (handler) => async (...args) => {
    try {
      await handler(...args);
    } catch (error) {
      console.error('Socket error:', error);
      socket.emit('error', {
        message: error.message,
        code: error.code || 'INTERNAL_ERROR',
      });
    }
  };

  socket.on('action', withErrorHandler(async (data, callback) => {
    // ... handle action
    callback({ success: true });
  }));
});

// Client-side reconnection handling
socket.on('connect', () => {
  console.log('Connected');

  // Rejoin rooms after reconnection
  if (currentRoom) {
    socket.emit('join', { room: currentRoom });
  }
});

socket.on('disconnect', (reason) => {
  console.log('Disconnected:', reason);

  if (reason === 'io server disconnect') {
    // Server disconnected us, need to reconnect manually
    socket.connect();
  }
  // Otherwise Socket.IO will try to reconnect automatically
});

socket.on('reconnect', (attemptNumber) => {
  console.log('Reconnected after', attemptNumber, 'attempts');
});

socket.on('reconnect_attempt', (attemptNumber) => {
  console.log('Reconnection attempt', attemptNumber);
});

socket.on('reconnect_error', (error) => {
  console.log('Reconnection error:', error);
});

socket.on('reconnect_failed', () => {
  console.log('Reconnection failed');
  // Show user a "connection lost" UI
});

// Custom reconnection strategy
const socket = io(URL, {
  reconnection: true,
  reconnectionAttempts: 10,
  reconnectionDelay: 1000,
  reconnectionDelayMax: 5000,
  randomizationFactor: 0.5,
});

Scaling with Redis

// npm install @socket.io/redis-adapter redis

const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

async function setupRedisAdapter(io) {
  const pubClient = createClient({ url: process.env.REDIS_URL });
  const subClient = pubClient.duplicate();

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

  io.adapter(createAdapter(pubClient, subClient));

  console.log('Socket.IO Redis adapter configured');
}

// Now Socket.IO events work across multiple servers
// Each server connects to the same Redis instance

// Broadcasting from any server reaches all clients
io.emit('global-event', data);

// Room-based events also work across servers
io.to('room-123').emit('room-event', data);

// Sticky sessions are recommended for connection stability
// Configure your load balancer accordingly

Best Practices

// 1. Always clean up listeners
useEffect(() => {
  socket.on('event', handler);
  return () => socket.off('event', handler);
}, []);

// 2. Use acknowledgments for important actions
socket.emit('critical-action', data, (response) => {
  if (response.success) {
    // Handle success
  } else {
    // Handle failure, maybe retry
  }
});

// 3. Implement heartbeat/ping-pong
const socket = io(URL, {
  pingTimeout: 60000,
  pingInterval: 25000,
});

// 4. Rate limiting on server
const rateLimiter = new Map();

socket.on('message', (data) => {
  const userId = socket.user.id;
  const now = Date.now();
  const userRate = rateLimiter.get(userId) || { count: 0, reset: now + 60000 };

  if (now > userRate.reset) {
    userRate.count = 0;
    userRate.reset = now + 60000;
  }

  if (userRate.count >= 100) {
    socket.emit('error', { message: 'Rate limit exceeded' });
    return;
  }

  userRate.count++;
  rateLimiter.set(userId, userRate);

  // Process message...
});

// 5. Validate all incoming data
const Joi = require('joi');

const messageSchema = Joi.object({
  content: Joi.string().max(1000).required(),
  room: Joi.string().required(),
});

socket.on('message', async (data) => {
  const { error, value } = messageSchema.validate(data);
  if (error) {
    socket.emit('error', { message: error.message });
    return;
  }
  // Process validated data...
});

Key Takeaways

  • WebSockets enable real-time bi-directional communication
  • Socket.IO simplifies WebSocket implementation with fallbacks
  • Rooms allow broadcasting to specific groups of clients
  • Namespaces separate different communication channels
  • Always implement authentication for production apps
  • Use Redis adapter for horizontal scaling
  • Clean up event listeners to prevent memory leaks
  • Implement proper error handling and reconnection logic