Backend

Building Chat Applications with Socket.io

Mayur Dabhi
Mayur Dabhi
May 3, 2026
15 min read

Real-time communication has become a cornerstone of modern web applications. From Slack to Discord, from live customer support to collaborative tools — users expect instant message delivery, typing indicators, and online presence all working seamlessly. Socket.io makes this possible by abstracting the complexities of WebSocket connections and providing an elegant, event-driven API that works reliably across all browsers and environments. In this guide, you'll build a full-featured chat application from scratch, covering rooms, private messaging, typing indicators, and production deployment.

Why Socket.io?

Socket.io automatically falls back to HTTP long-polling when WebSockets are unavailable, handles reconnection logic, and provides room and namespace abstractions out of the box. With over 58,000 GitHub stars and powering apps serving millions of concurrent users, it remains the gold standard for Node.js real-time communication.

How Socket.io Works

Before writing any code, it's worth understanding how Socket.io differs from plain HTTP. Traditional HTTP follows a request-response cycle — the client must initiate every communication. For chat apps, this creates two problems: you need to poll the server constantly for new messages (inefficient), or you miss messages entirely.

Socket.io upgrades the connection to a persistent, bidirectional channel using WebSockets (falling back to long-polling in constrained environments). Once the handshake completes, both client and server can push events at any time:

HTTP Polling Client Server GET /messages? 200 (empty) GET /messages? 200 (empty) GET /messages? 200 (1 new msg!) Wasted requests until msg arrives Socket.io (WebSocket) Client Server Handshake (once) Persistent connection open Server pushes message! Client sends message Server broadcasts to room Instant, zero-overhead delivery

HTTP Polling vs Socket.io persistent connection

FeatureHTTP PollingSocket.io
Connection overheadPer-requestOne-time handshake
Server pushNot supportedNative
Latency200ms–2000ms<20ms
Bandwidth usageHigh (headers repeated)Low (minimal framing)
Fallback supportN/AAuto (long-polling)
ReconnectionManualAutomatic

Setting Up the Project

We'll build a chat server with Node.js + Express + Socket.io and a vanilla JS frontend. No frontend framework is required — Socket.io's client library handles all the real-time plumbing.

1

Initialize the project

Create a new directory and initialize a Node.js package, then install the required dependencies.

Terminal
mkdir chat-app && cd chat-app
npm init -y

# Core dependencies
npm install express socket.io

# Optional but useful
npm install dotenv cors

# Dev dependency
npm install --save-dev nodemon
2

Project structure

Keep the project organized with separate files for server logic, socket handlers, and the public frontend.

Project layout
chat-app/
├── server.js          # Express + Socket.io server
├── handlers/
│   └── chatHandler.js # Socket event handlers
├── public/
│   ├── index.html     # Chat UI
│   ├── style.css      # Styles
│   └── client.js      # Socket.io client logic
├── .env
└── package.json
3

Add start scripts

Update package.json to add convenient start and dev scripts.

package.json (scripts section)
"scripts": {
  "start": "node server.js",
  "dev": "nodemon server.js"
}

Building the Chat Server

The server is responsible for maintaining all active socket connections, routing events between clients, and managing room membership. Socket.io wraps the Node.js http module and attaches itself to the same port as your Express app — no second port needed.

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

const app = express();
const server = http.createServer(app);

const io = new Server(server, {
  cors: {
    origin: process.env.CLIENT_URL || '*',
    methods: ['GET', 'POST'],
  },
  // Ping every 25s, disconnect after 60s of no pong
  pingTimeout: 60000,
  pingInterval: 25000,
});

// Serve frontend
app.use(express.static(path.join(__dirname, 'public')));

// In-memory store: socketId → { username, room }
const users = {};

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

  // 1. User joins a room
  socket.on('join_room', ({ username, room }) => {
    // Leave any previous room
    const prev = users[socket.id];
    if (prev) {
      socket.leave(prev.room);
      io.to(prev.room).emit('user_left', {
        username: prev.username,
        time: new Date().toISOString(),
      });
    }

    // Register and join
    users[socket.id] = { username, room };
    socket.join(room);

    // Tell the joiner their history (omitted here — add DB lookup)
    socket.emit('room_joined', { room, username });

    // Notify others in the room
    socket.to(room).emit('user_joined', {
      username,
      time: new Date().toISOString(),
    });

    // Broadcast updated user list for this room
    broadcastRoomUsers(room);
  });

  // 2. New chat message
  socket.on('send_message', ({ message, room }) => {
    const user = users[socket.id];
    if (!user) return;

    const payload = {
      id: `${Date.now()}-${socket.id}`,
      username: user.username,
      message,
      time: new Date().toISOString(),
    };

    // Emit to everyone in the room (including sender)
    io.to(room).emit('receive_message', payload);
  });

  // 3. Typing indicator
  socket.on('typing', ({ room, isTyping }) => {
    const user = users[socket.id];
    if (!user) return;
    socket.to(room).emit('user_typing', {
      username: user.username,
      isTyping,
    });
  });

  // 4. Private message
  socket.on('private_message', ({ toSocketId, message }) => {
    const sender = users[socket.id];
    if (!sender) return;

    const payload = {
      from: sender.username,
      fromId: socket.id,
      message,
      time: new Date().toISOString(),
    };

    // Send to recipient
    io.to(toSocketId).emit('private_message', payload);
    // Echo back to sender
    socket.emit('private_message', { ...payload, isSelf: true });
  });

  // 5. Cleanup on disconnect
  socket.on('disconnect', () => {
    const user = users[socket.id];
    if (user) {
      socket.to(user.room).emit('user_left', {
        username: user.username,
        time: new Date().toISOString(),
      });
      delete users[socket.id];
      broadcastRoomUsers(user.room);
    }
    console.log(`Socket disconnected: ${socket.id}`);
  });
});

function broadcastRoomUsers(room) {
  const roomUsers = Object.values(users)
    .filter(u => u.room === room)
    .map(u => u.username);
  io.to(room).emit('room_users', roomUsers);
}

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Chat server running on http://localhost:${PORT}`);
});
In-memory vs persistent storage

The users object above is in-memory and resets on server restart. For production, store messages in a database (MongoDB, PostgreSQL) and user sessions in Redis so history survives server restarts and scales across multiple nodes.

Creating the Chat Frontend

The client side uses Socket.io's browser bundle served directly from the Node.js server — no CDN or bundler required. The key is to respond to events emitted by the server and emit events when the user interacts with the UI.

public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Socket Chat</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <!-- Join Screen -->
  <div id="join-screen">
    <h2>Join Chat</h2>
    <input id="username" placeholder="Your name" maxlength="20">
    <input id="room" placeholder="Room name (e.g. general)" value="general">
    <button id="join-btn">Join Room</button>
  </div>

  <!-- Chat Screen -->
  <div id="chat-screen" class="hidden">
    <aside id="sidebar">
      <h3>Online — <span id="room-name"></span></h3>
      <ul id="user-list"></ul>
    </aside>
    <main>
      <div id="messages"></div>
      <div id="typing-indicator"></div>
      <form id="message-form">
        <input id="message-input" placeholder="Type a message…" autocomplete="off">
        <button type="submit">Send</button>
      </form>
    </main>
  </div>

  <!-- Socket.io client (auto-served by the server) -->
  <script src="/socket.io/socket.io.js"></script>
  <script src="client.js"></script>
</body>
</html>
public/client.js
const socket = io();

let currentUser = '';
let currentRoom = '';
let typingTimer;

// ── Join ──────────────────────────────────────────
document.getElementById('join-btn').addEventListener('click', () => {
  const username = document.getElementById('username').value.trim();
  const room = document.getElementById('room').value.trim() || 'general';

  if (!username) return alert('Please enter a username');

  currentUser = username;
  currentRoom = room;

  socket.emit('join_room', { username, room });
});

socket.on('room_joined', ({ room }) => {
  document.getElementById('join-screen').classList.add('hidden');
  document.getElementById('chat-screen').classList.remove('hidden');
  document.getElementById('room-name').textContent = room;
});

// ── Messages ──────────────────────────────────────
document.getElementById('message-form').addEventListener('submit', (e) => {
  e.preventDefault();
  const input = document.getElementById('message-input');
  const message = input.value.trim();
  if (!message) return;

  socket.emit('send_message', { message, room: currentRoom });
  socket.emit('typing', { room: currentRoom, isTyping: false });
  input.value = '';
});

socket.on('receive_message', ({ username, message, time }) => {
  const isSelf = username === currentUser;
  appendMessage({ username, message, time, isSelf });
});

function appendMessage({ username, message, time, isSelf, isSystem }) {
  const div = document.createElement('div');
  div.className = isSystem
    ? 'msg-system'
    : isSelf ? 'msg msg-self' : 'msg msg-other';

  if (!isSystem) {
    div.innerHTML = `
      <span class="msg-user">${username}</span>
      <span class="msg-text">${escapeHtml(message)}</span>
      <span class="msg-time">${formatTime(time)}</span>
    `;
  } else {
    div.textContent = message;
  }

  const container = document.getElementById('messages');
  container.appendChild(div);
  container.scrollTop = container.scrollHeight;
}

// ── System events ─────────────────────────────────
socket.on('user_joined', ({ username }) => {
  appendMessage({ message: `${username} joined the room`, isSystem: true });
});

socket.on('user_left', ({ username }) => {
  appendMessage({ message: `${username} left the room`, isSystem: true });
});

// ── User list ─────────────────────────────────────
socket.on('room_users', (users) => {
  const ul = document.getElementById('user-list');
  ul.innerHTML = users
    .map(u => `<li class="${u === currentUser ? 'you' : ''}">${u}</li>`)
    .join('');
});

// ── Typing indicator ──────────────────────────────
document.getElementById('message-input').addEventListener('input', () => {
  socket.emit('typing', { room: currentRoom, isTyping: true });
  clearTimeout(typingTimer);
  typingTimer = setTimeout(() => {
    socket.emit('typing', { room: currentRoom, isTyping: false });
  }, 1500);
});

const activeTypers = new Set();

socket.on('user_typing', ({ username, isTyping }) => {
  if (isTyping) activeTypers.add(username);
  else activeTypers.delete(username);

  const el = document.getElementById('typing-indicator');
  if (activeTypers.size === 0) {
    el.textContent = '';
  } else if (activeTypers.size === 1) {
    el.textContent = `${[...activeTypers][0]} is typing…`;
  } else {
    el.textContent = `${activeTypers.size} people are typing…`;
  }
});

// ── Helpers ───────────────────────────────────────
function escapeHtml(str) {
  return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}

function formatTime(iso) {
  return new Date(iso).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' });
}
Always sanitize output

Never inject raw user input into innerHTML. The escapeHtml helper above prevents XSS by escaping HTML entities before rendering messages. For richer formatting, use a whitelist-based sanitizer library like DOMPurify.

Chat Rooms and Namespaces

Socket.io provides two mechanisms for separating traffic: rooms and namespaces. Understanding the distinction will prevent you from over-engineering or under-architecting your chat app.

Rooms are lightweight, server-side groupings within a single namespace. A socket can join multiple rooms simultaneously. Perfect for chat channels, DM threads, or game lobbies.

// Server — join a room
socket.join('room:general');
socket.join('room:engineering');

// Leave a room
socket.leave('room:general');

// Emit to everyone IN the room (including sender)
io.to('room:general').emit('message', data);

// Emit to everyone EXCEPT the sender
socket.to('room:general').emit('message', data);

// Check which rooms a socket is in
console.log(socket.rooms); // Set { socketId, 'room:general', 'room:engineering' }

// Get all sockets in a room
const sockets = await io.in('room:general').fetchSockets();
console.log(sockets.length); // count of members

Namespaces create completely separate communication channels with their own event handlers and middleware — like separate Socket.io instances on one server. Use them to isolate distinct features (chat vs notifications vs admin).

// Server — separate namespaces
const chatNsp = io.of('/chat');
const adminNsp = io.of('/admin');

chatNsp.on('connection', (socket) => {
  console.log('chat user connected');
  socket.on('send_message', handler);
});

adminNsp.use((socket, next) => {
  // Middleware only for /admin namespace
  if (socket.handshake.auth.token === process.env.ADMIN_TOKEN) {
    next();
  } else {
    next(new Error('Unauthorized'));
  }
});

adminNsp.on('connection', (socket) => {
  // Admin-only events
  socket.on('kick_user', ({ socketId }) => {
    io.sockets.sockets.get(socketId)?.disconnect();
  });
});

// Client — connect to a specific namespace
const chatSocket = io('/chat');
const adminSocket = io('/admin', {
  auth: { token: 'secret-admin-token' }
});

Socket.io provides fine-grained control over who receives each event. Knowing these targets is essential for building correct behavior.

// To a specific socket
io.to(socketId).emit('event', data);

// To everyone in a room
io.to('room-name').emit('event', data);

// To everyone in a room EXCEPT the sender
socket.to('room-name').emit('event', data);

// To everyone connected (all namespaces)
io.emit('event', data);

// To everyone EXCEPT the current socket
socket.broadcast.emit('event', data);

// To multiple rooms at once
io.to('room1').to('room2').emit('event', data);

// Volatile — skip if client is not connected
io.volatile.emit('typing', data); // OK to lose

// With acknowledgment (request/response over socket)
socket.emit('save_message', payload, (ack) => {
  console.log('Server confirmed:', ack.status);
});

Private Messaging

One-on-one messaging is a common requirement. Since each Socket.io connection gets a unique socket.id, you can address any connected user directly. The challenge is mapping a human-readable username to its current socket ID — especially after reconnects, which assign a new ID.

server.js — username-to-socket mapping
// Map username → socketId for DMs
const usernameToSocket = new Map();

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

  socket.on('register', (username) => {
    usernameToSocket.set(username, socket.id);
    socket.data.username = username;
  });

  socket.on('private_message', ({ toUsername, message }) => {
    const recipientId = usernameToSocket.get(toUsername);

    if (!recipientId) {
      socket.emit('error', { msg: `${toUsername} is offline` });
      return;
    }

    const payload = {
      from: socket.data.username,
      message,
      time: new Date().toISOString(),
    };

    // Deliver to recipient
    io.to(recipientId).emit('private_message', payload);
    // Echo to sender (so their UI updates too)
    socket.emit('private_message', { ...payload, isSelf: true });
  });

  socket.on('disconnect', () => {
    const username = socket.data.username;
    if (username) usernameToSocket.delete(username);
  });
});

Message Delivery Receipts

Use Socket.io acknowledgements to implement read receipts — a callback passed as the last argument to emit is called by the server when the event is processed:

Acknowledgement pattern
// Client sends message with ack callback
socket.emit('send_message', { message, room }, (ack) => {
  if (ack.status === 'delivered') {
    markMessageDelivered(ack.messageId);
  }
});

// Server handles and responds via ack
socket.on('send_message', ({ message, room }, callback) => {
  const msgId = saveMessage(message); // your DB call
  io.to(room).emit('receive_message', { message, id: msgId });

  // Invoke the callback — this fires the client's ack handler
  callback({ status: 'delivered', messageId: msgId });
});

Online Presence and Typing Indicators

Two features that instantly make a chat app feel alive: knowing who's online and seeing when someone is composing a reply. Both are straightforward to implement correctly if you avoid the common pitfalls.

Online presence

Maintain a server-side map and broadcast updates on connect/disconnect. Crucially, use socket.on('disconnect') and not a custom "logout" event — clients can close the browser tab without firing custom events.

server.js — presence tracking
// Broadcast full online list whenever membership changes
function broadcastPresence() {
  const online = [...io.sockets.sockets.values()]
    .filter(s => s.data.username)
    .map(s => ({ username: s.data.username, id: s.id }));

  io.emit('presence_update', online);
}

io.on('connection', (socket) => {
  socket.on('register', (username) => {
    socket.data.username = username;
    broadcastPresence();
  });

  socket.on('disconnect', () => {
    broadcastPresence();
  });
});

Typing indicators done right

The naive approach emits a typing event on every keypress. This floods the server. A better pattern debounces on the client — emit "started typing" immediately, then emit "stopped typing" after 1.5 seconds of inactivity. The server simply relays these to other room members:

Debounced typing — client.js
let typingTimer = null;
let isCurrentlyTyping = false;

input.addEventListener('input', () => {
  if (!isCurrentlyTyping) {
    isCurrentlyTyping = true;
    socket.emit('typing', { room, isTyping: true });
  }

  clearTimeout(typingTimer);
  typingTimer = setTimeout(() => {
    isCurrentlyTyping = false;
    socket.emit('typing', { room, isTyping: false });
  }, 1500);
});

// Also stop typing on send
form.addEventListener('submit', () => {
  clearTimeout(typingTimer);
  isCurrentlyTyping = false;
  socket.emit('typing', { room, isTyping: false });
});
FeatureImplementationKey consideration
Typing indicatorDebounced emitStop on send + timeout
Online presenceServer-side map + broadcastTrust disconnect, not logout event
Read receiptsAcknowledgement callbacksOnly reliable for connected sockets
Message historyDB query on room_joinedPaginate — don't load thousands at once
Unread badgeClient-side counter reset on focusUse Page Visibility API

Deployment and Scaling

Socket.io apps have a specific deployment constraint that catches most developers off guard: sticky sessions. When you run multiple server instances behind a load balancer, a client's socket reconnection requests must always reach the same server instance that holds its connection state.

Client A Client B Load Balancer Server 1 Server 2 Server 3 Redis Pub/Sub adapter Redis adapter syncs events across all server instances

Multi-server Socket.io architecture with Redis adapter

Single-server pitfall

If you deploy multiple Node.js processes without the Redis adapter, a message sent by a client connected to Server 1 will never reach clients connected to Server 2 or 3. Always configure the adapter before scaling horizontally.

server.js — Redis adapter setup
npm install @socket.io/redis-adapter ioredis
server.js — adapter configuration
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('ioredis');

const pubClient = createClient({ host: process.env.REDIS_HOST, port: 6379 });
const subClient = pubClient.duplicate();

Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
  io.adapter(createAdapter(pubClient, subClient));
  server.listen(PORT);
  console.log(`Server on port ${PORT}`);
});

Production deployment checklist

Before you go live

  • CORS: Set origin to your specific domain, not '*'
  • Rate limiting: Throttle send_message events per socket to prevent spam (e.g., max 5 per second)
  • Input validation: Validate message length, sanitize all user content server-side
  • Authentication: Verify JWT or session cookie in the io.use() middleware before the connection is accepted
  • Redis adapter: Required for more than one process/pod
  • Sticky sessions: Configure your load balancer (Nginx, AWS ALB) for IP hash or cookie-based affinity
  • Connection limits: Monitor open socket count; a Node.js process comfortably handles 10,000–100,000 concurrent connections
  • Graceful shutdown: Close all sockets before process exit to trigger proper client reconnection

Key Takeaways

Building a production-ready chat application with Socket.io is approachable once you understand the event-driven model. Here's what to carry forward from this guide:

Summary

  • WebSockets beat polling — persistent connections deliver messages with near-zero latency and far less bandwidth
  • Rooms for grouping, namespaces for isolation — rooms are lightweight channel membership; namespaces separate distinct features with their own middleware
  • Debounce typing events — emit "started typing" immediately, emit "stopped" after 1.5s of inactivity to avoid flooding the server
  • Acknowledgements for reliability — use the callback pattern for critical events where you need confirmation of delivery
  • Redis adapter before horizontal scaling — without it, events don't cross server boundaries and your chat silently breaks
  • Sanitize on the server — never trust message content from the client; always escape output when injecting into the DOM
"The real power of Socket.io isn't just sending messages — it's the event-driven mental model that maps perfectly to how users actually interact with each other in real time."

With the foundation from this guide you can now extend the app with message persistence (MongoDB or PostgreSQL), file/image sharing, end-to-end encryption, push notifications for offline users, or a React/Vue frontend. Each feature slots naturally into the event-driven architecture you've already built.

Socket.io Real-time Chat Node.js WebSockets Backend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.