Episode 3 — NodeJS MongoDB Backend Architecture / 3.15 — Realtime Communication WebSockets

3.15.c — Socket.io Setup and Basics

Socket.io is a JavaScript library for real-time, bidirectional, event-based communication. It provides a simple API on top of WebSocket with automatic fallbacks, reconnection, rooms, namespaces, and broadcasting out of the box.


<< Previous: 3.15.b — HTTP Polling & Alternatives | Next: 3.15.d — Rooms & Namespaces >>


1. What is Socket.io?

Socket.io consists of two parts:

  • Server library (socket.io) — runs on Node.js
  • Client library (socket.io-client) — runs in the browser or Node.js

It abstracts away the complexity of WebSocket management and provides a high-level, event-driven API.

┌─────────────────┐         ┌──────────────────┐
│  Browser Client  │ ══════> │  Express + Socket │
│  socket.io-client│ <══════ │  .io Server       │
└─────────────────┘         └──────────────────┘
   emit('chat', msg)           on('chat', callback)
   on('response', cb)          emit('response', data)

2. Socket.io vs Raw WebSocket

FeatureRaw WebSocketSocket.io
ReconnectionManualAutomatic with backoff
Fallback transportsNoneLong polling, then upgrade
Event-based APIonmessage (single handler)on('eventName') (multiple)
RoomsNoneBuilt-in
NamespacesNoneBuilt-in
BroadcastingManual to each clientio.emit(), socket.broadcast.emit()
Binary supportManualAutomatic detection
AcknowledgementsNoneCallback-based acks
MiddlewareNoneio.use()
Packet sizeSmallerSlightly larger (protocol overhead)

3. Installation

# Server-side
npm install socket.io

# Client-side (for frontend frameworks or Node.js clients)
npm install socket.io-client

# For plain HTML, use CDN:
# <script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>

4. Setting Up Socket.io with Express

Basic Setup

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

const app = express();

// IMPORTANT: Create HTTP server from Express app
// Socket.io needs the raw HTTP server, not the Express app
const server = http.createServer(app);

// Attach Socket.io to the HTTP server
const io = new Server(server);

// Serve static files (for the client)
app.use(express.static('public'));

// REST API routes still work normally
app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

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

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

// IMPORTANT: Use server.listen(), NOT app.listen()
const PORT = 3000;
server.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Common mistake: Using app.listen() instead of server.listen(). The app.listen() method creates its own HTTP server internally, which Socket.io will not be attached to.


5. CORS Configuration

When your frontend and backend run on different origins (e.g., React on port 5173 and Express on port 3000), you need CORS configuration:

const io = new Server(server, {
  cors: {
    origin: 'http://localhost:5173',       // Frontend URL
    // origin: ['http://localhost:5173', 'https://myapp.com'], // Multiple origins
    // origin: '*',                         // All origins (dev only!)
    methods: ['GET', 'POST'],
    credentials: true                       // Allow cookies/auth headers
  }
});

6. Client Connection

In an HTML File

<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Socket.io Chat</title>
</head>
<body>
  <h1>Chat App</h1>

  <!-- Socket.io auto-serves its client library at this path -->
  <script src="/socket.io/socket.io.js"></script>
  <script>
    // Connect to the server (same origin)
    const socket = io();

    // Or connect to a specific URL
    // const socket = io('http://localhost:3000');

    socket.on('connect', () => {
      console.log('Connected! My ID:', socket.id);
    });
  </script>
</body>
</html>

In a React/Vite Project

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

const SERVER_URL = 'http://localhost:3000';

export const socket = io(SERVER_URL, {
  autoConnect: false,     // Don't connect until we explicitly call connect()
  auth: {
    token: localStorage.getItem('jwt') // Send auth token on connection
  }
});
// src/App.jsx
import { useEffect, useState } from 'react';
import { socket } from './socket';

function App() {
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    socket.connect();

    socket.on('connect', () => setIsConnected(true));
    socket.on('disconnect', () => setIsConnected(false));

    // Cleanup on unmount
    return () => {
      socket.off('connect');
      socket.off('disconnect');
      socket.disconnect();
    };
  }, []);

  return <div>Status: {isConnected ? 'Connected' : 'Disconnected'}</div>;
}

7. Core Events and API

Connection Events (Server-Side)

io.on('connection', (socket) => {
  // socket.id — unique identifier for this connection
  console.log('New connection:', socket.id);

  // socket.handshake — info about the initial handshake
  console.log('Headers:', socket.handshake.headers);
  console.log('Query:', socket.handshake.query);
  console.log('Auth:', socket.handshake.auth);

  // DISCONNECT — client disconnected
  socket.on('disconnect', (reason) => {
    console.log(`Disconnected: ${socket.id}, Reason: ${reason}`);
    // Reasons: 'io server disconnect', 'io client disconnect',
    //          'ping timeout', 'transport close', 'transport error'
  });

  // ERROR
  socket.on('error', (error) => {
    console.error('Socket error:', error);
  });
});

Emitting and Listening to Custom Events

// ========== SERVER ==========
io.on('connection', (socket) => {

  // LISTENING for events from this client
  socket.on('chat-message', (data) => {
    console.log(`${data.user}: ${data.text}`);

    // Process the message, then broadcast to others
    socket.broadcast.emit('chat-message', {
      user: data.user,
      text: data.text,
      timestamp: Date.now()
    });
  });

  // EMITTING an event to this specific client
  socket.emit('welcome', {
    message: 'Welcome to the chat!',
    onlineUsers: getOnlineUserCount()
  });

  // Listening for typing indicator
  socket.on('typing', (username) => {
    socket.broadcast.emit('user-typing', username);
  });

  socket.on('stop-typing', (username) => {
    socket.broadcast.emit('user-stopped-typing', username);
  });
});
// ========== CLIENT ==========
const socket = io();

// EMIT event to server
socket.emit('chat-message', {
  user: 'Alice',
  text: 'Hello everyone!'
});

// LISTEN for events from server
socket.on('welcome', (data) => {
  console.log(data.message);       // "Welcome to the chat!"
  console.log(data.onlineUsers);   // 5
});

socket.on('chat-message', (data) => {
  displayMessage(data.user, data.text, data.timestamp);
});

// Typing indicator
inputField.addEventListener('input', () => {
  socket.emit('typing', currentUser.name);
  clearTimeout(typingTimeout);
  typingTimeout = setTimeout(() => {
    socket.emit('stop-typing', currentUser.name);
  }, 2000);
});

8. Broadcasting Patterns

io.on('connection', (socket) => {

  // 1. Send to THIS client only
  socket.emit('private', 'Only you get this');

  // 2. Send to ALL connected clients (including sender)
  io.emit('announcement', 'Server is restarting in 5 minutes');

  // 3. Send to ALL clients EXCEPT the sender
  socket.broadcast.emit('user-joined', `${socket.id} joined the chat`);

  // 4. Send to a specific ROOM (all members, including sender)
  io.to('room-name').emit('room-event', data);

  // 5. Send to a specific ROOM (excluding sender)
  socket.to('room-name').emit('room-event', data);

  // 6. Send to multiple rooms
  io.to('room1').to('room2').emit('multi-room', data);

  // 7. Send to a specific socket by ID
  io.to(targetSocketId).emit('direct-message', data);
});

9. Acknowledgements (Callbacks)

Socket.io supports acknowledgements -- the receiver can respond to confirm receipt:

// ========== CLIENT ==========
socket.emit('save-message', { text: 'Hello' }, (response) => {
  // This callback fires when the server acknowledges
  if (response.status === 'ok') {
    console.log('Message saved! ID:', response.id);
  } else {
    console.error('Failed to save:', response.error);
  }
});

// ========== SERVER ==========
socket.on('save-message', async (data, callback) => {
  try {
    const message = await Message.create({ text: data.text });
    callback({ status: 'ok', id: message._id });
  } catch (error) {
    callback({ status: 'error', error: error.message });
  }
});

10. Complete Chat Application Example

Server

// server.js
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, {
  cors: { origin: '*' }
});

app.use(express.static('public'));

// Track online users
const onlineUsers = new Map(); // socketId -> { username, joinedAt }

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

  // User joins the chat
  socket.on('user-join', (username) => {
    onlineUsers.set(socket.id, {
      username,
      joinedAt: new Date()
    });

    // Notify everyone
    io.emit('user-list', Array.from(onlineUsers.values()));
    socket.broadcast.emit('system-message', {
      text: `${username} joined the chat`,
      timestamp: Date.now()
    });
  });

  // Handle chat messages
  socket.on('chat-message', (data, callback) => {
    const user = onlineUsers.get(socket.id);
    if (!user) return callback?.({ error: 'Not authenticated' });

    const message = {
      id: Date.now(),
      user: user.username,
      text: data.text,
      timestamp: Date.now()
    };

    // Broadcast to all (including sender, so they see it in the feed)
    io.emit('chat-message', message);
    callback?.({ status: 'ok', id: message.id });
  });

  // Typing indicator
  socket.on('typing', () => {
    const user = onlineUsers.get(socket.id);
    if (user) {
      socket.broadcast.emit('user-typing', user.username);
    }
  });

  socket.on('stop-typing', () => {
    const user = onlineUsers.get(socket.id);
    if (user) {
      socket.broadcast.emit('user-stopped-typing', user.username);
    }
  });

  // Handle disconnect
  socket.on('disconnect', () => {
    const user = onlineUsers.get(socket.id);
    if (user) {
      onlineUsers.delete(socket.id);
      io.emit('user-list', Array.from(onlineUsers.values()));
      io.emit('system-message', {
        text: `${user.username} left the chat`,
        timestamp: Date.now()
      });
    }
  });
});

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

Client

<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Socket.io Chat</title>
  <style>
    #messages { list-style: none; padding: 0; max-height: 400px; overflow-y: auto; }
    #messages li { padding: 5px 10px; border-bottom: 1px solid #eee; }
    .system { color: #999; font-style: italic; }
    #typing { color: #666; height: 20px; }
  </style>
</head>
<body>
  <div id="login">
    <input id="username" placeholder="Enter your name" />
    <button onclick="joinChat()">Join Chat</button>
  </div>
  <div id="chat" style="display:none">
    <h3>Online: <span id="online-count">0</span></h3>
    <ul id="messages"></ul>
    <p id="typing"></p>
    <input id="message-input" placeholder="Type a message..." />
    <button onclick="sendMessage()">Send</button>
  </div>

  <script src="/socket.io/socket.io.js"></script>
  <script>
    const socket = io();
    let typingTimeout;

    function joinChat() {
      const username = document.getElementById('username').value.trim();
      if (!username) return alert('Enter a name!');

      socket.emit('user-join', username);
      document.getElementById('login').style.display = 'none';
      document.getElementById('chat').style.display = 'block';
      document.getElementById('message-input').focus();
    }

    function sendMessage() {
      const input = document.getElementById('message-input');
      const text = input.value.trim();
      if (!text) return;

      socket.emit('chat-message', { text }, (response) => {
        if (response.error) alert(response.error);
      });
      input.value = '';
      socket.emit('stop-typing');
    }

    // Listen for messages
    socket.on('chat-message', (msg) => {
      const li = document.createElement('li');
      li.textContent = `${msg.user}: ${msg.text}`;
      document.getElementById('messages').appendChild(li);
    });

    socket.on('system-message', (msg) => {
      const li = document.createElement('li');
      li.className = 'system';
      li.textContent = msg.text;
      document.getElementById('messages').appendChild(li);
    });

    socket.on('user-list', (users) => {
      document.getElementById('online-count').textContent = users.length;
    });

    // Typing indicator
    document.getElementById('message-input')?.addEventListener('keyup', (e) => {
      if (e.key === 'Enter') return sendMessage();
      socket.emit('typing');
      clearTimeout(typingTimeout);
      typingTimeout = setTimeout(() => socket.emit('stop-typing'), 2000);
    });

    socket.on('user-typing', (username) => {
      document.getElementById('typing').textContent = `${username} is typing...`;
    });

    socket.on('user-stopped-typing', () => {
      document.getElementById('typing').textContent = '';
    });
  </script>
</body>
</html>

Key Takeaways

  1. Socket.io has two parts: server (socket.io) and client (socket.io-client)
  2. Always create an http.createServer(app) and pass it to Socket.io -- do not use app.listen()
  3. Communication is event-based: emit('event', data) to send, on('event', callback) to receive
  4. Broadcasting sends to all or selected clients: io.emit(), socket.broadcast.emit()
  5. Acknowledgements let the receiver respond to confirm receipt via callbacks
  6. Configure CORS when frontend and backend are on different origins
  7. Socket.io auto-serves its client library at /socket.io/socket.io.js

Explain-It Challenge

Scenario: Your team built the chat app above, but users report that messages sometimes appear duplicated. After debugging, you notice that the useEffect in the React component runs twice (React Strict Mode).

Explain why this causes duplicate messages, what the root cause is, and how you would fix it. Hint: Think about event listener cleanup and the Socket.io off() method.


<< Previous: 3.15.b — HTTP Polling & Alternatives | Next: 3.15.d — Rooms & Namespaces >>