Socket.IO in MERN Stack: A Zero-to-Hero Guide for Beginners | HiringMine
Community · 33 min read Socket.IO in MERN Stack: A Zero-to-Hero Guide for Beginners Real-time applications are everywhere. When someone sends you a message on WhatsApp, you do not refresh the page to receive it. When a driver changes location on a ride-hailing
MS Muhammad Sufiyan Software Engineer · Jul 26
Backend Engineering Hub T-
Real-time applications are everywhere. When someone sends you a message on WhatsApp, you do not refresh the page to receive it. When a driver changes location on a ride-hailing application, the map updates automatically. When someone starts typing in a chat, you immediately see a typing indicator.
These features feel normal to users, but they cannot be implemented efficiently using only traditional request-response APIs.
This is where sockets come in.
In this beginner-friendly guide, we will understand:
Why sockets are needed How they differ from normal HTTP APIs What WebSocket and Socket.IO are How emit() and on() work How to broadcast events How Socket.IO rooms work How to build a real-time chat application How to add authentication and MongoDB How reconnection and message delivery work How to deploy and scale Socket.IO applications The goal is not to memorize random Socket.IO commands.
The goal is to understand the problem sockets solve.
1. The Problem with Normal HTTP APIs MERN Stack developers are already familiar with the normal request-response model.
React sends a request:
React → Express
Express processes the request and sends a response:
React ← Express
For example:
const response = await fetch("/api/products");
const products = await response.json();
This model works perfectly for:
Signup and login Creating posts Loading products Updating profiles Deleting records Most CRUD operations The browser asks for something, and the server responds.
The problem begins when the server needs to send new information immediately, without waiting for the browser to ask again.
A new chat message arrives A user starts typing A delivery driver changes location A recruiter updates an application status A live dashboard receives new analytics A multiplayer game character moves A support agent replies to a customer A user receives a notification With a normal REST request, the server usually sends data only after the client requests it.
A real-time application needs something more:
The server must be able to send new information to an already connected browser.
2. A Simple Real-Life Analogy Imagine that you are waiting for a parcel.
There are two possible approaches.
Approach 1: Repeatedly call the courier Every five seconds, you call:
This is similar to polling.
Client asks
Server answers
Client asks again
Server answers again
Client asks again
Server answers again
Most of these requests may return:
Approach 2: Keep a live communication channel I will notify you immediately when the parcel arrives. Now you do not need to call repeatedly.
This is similar to a persistent socket connection.
Client and server stay connected
↓
Server sends an update when something changes
That is the main idea behind real-time communication.
3. What Is Polling? Polling means repeatedly asking the server for new data after a fixed interval.
setInterval(async () => {
const response = await fetch("/api/messages");
const messages = await response.json();
setMessages(messages);
}, 3000);
This code asks the server for messages every three seconds.
It may work for a small project, but it has several problems.
Suppose no new message arrives for ten minutes.
The browser still sends a request every three seconds.
Many unnecessary network requests Repeated database queries Additional server load Delayed updates Poor scalability A message may arrive one second after the latest request. The user will not see it until the next polling request.
Polling is not always wrong, but it is not ideal for highly interactive real-time systems.
4. What Is WebSocket? WebSocket is a protocol that creates a persistent, two-way communication channel between the client and the server.
Browser <========================> Server
persistent connection
Once the connection is established:
The browser can send data at any time The server can send data at any time The connection stays open The client does not need to repeatedly request updates HTTP requests are like sending separate letters. A WebSocket connection is like keeping a phone call open. A raw browser WebSocket connection can look like this:
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", () => {
console.log("Connected to server");
socket.send("Hello server");
});
socket.addEventListener("message", (event) => {
console.log("Server says:", event.data);
});
Raw WebSocket works, but developers need to build many features themselves, including:
Reconnection Named event handling Rooms Broadcasting rules Acknowledgements Authentication patterns Scaling across servers This is why many JavaScript developers use Socket.IO.
5. What Is Socket.IO? Socket.IO is a JavaScript library for real-time communication between clients and servers.
It provides a clean event-based API and useful built-in features such as:
Automatic reconnection Named events Broadcasting Rooms Acknowledgements Authentication middleware Transport management Multi-server adapters Instead of manually sending raw messages, we can use meaningful event names.
socket.emit("send-message", {
text: "Hello everyone",
});
The server can listen for that event:
socket.on("send-message", (data) => {
console.log(data.text);
});
This is easier to understand than designing a custom message protocol from scratch.
6. WebSocket and Socket.IO Are Not the Same Thing This is an important interview question.
Socket.IO is a higher-level JavaScript library with its own communication protocol.
Socket.IO may use WebSocket as a transport, but a raw WebSocket client cannot directly communicate with a Socket.IO server.
For example, this is a raw WebSocket client:
const socket = new WebSocket("ws://localhost:5000");
This is a Socket.IO client:
import { io } from "socket.io-client";
const socket = io("http://localhost:5000");
They are not interchangeable.
For MERN Stack projects, Socket.IO is often easier for beginners because it already provides rooms, broadcasting, reconnection and acknowledgements.
7. Does Socket.IO Replace REST APIs? Socket.IO and REST APIs solve different problems.
Login and signup Loading stored data Creating records Updating profiles Uploading files Loading chat history Normal CRUD operations New messages Typing indicators Online user status Live notifications Real-time location updates Live dashboards Application-status updates Multiplayer events A useful architecture is:
REST API:
Load the existing state
Socket.IO:
Deliver changes to that state in real time
REST API → Load previous 50 messages
Socket.IO → Deliver every new message immediately
8. The Socket.IO Mental Model Socket.IO becomes easier when you understand four words:
Connection A live communication link between one client and the server.
Socket An object representing one connected client.
Event send-message
new-message
typing-start
typing-stop
order-updated
Listener A function waiting for a specific event.
socket.emit("say-hello", {
name: "Sufiyan",
});
socket.on("say-hello", (data) => {
console.log(data.name);
});
The most important pair is:
socket.emit("event-name", data);
socket.on("event-name", (data) => {
// Handle the event
});
emit = send an event
on = listen for an event
9. Your First Socket.IO Project Let us create the smallest possible Socket.IO application.
An Express server A Socket.IO server A React client A simple two-way event
Backend Setup mkdir socket-basics
cd socket-basics
mkdir backend
cd backend
npm init -y
npm install express socket.io cors
npm install --save-dev nodemon
{
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
}
}
const express = require("express");
const http = require("http");
const cors = require("cors");
const { Server } = require("socket.io");
const app = express();
/*
Express normally creates and manages its HTTP server internally
when we use app.listen().
Socket.IO needs access to the real Node.js HTTP server,
so we create it manually.
*/
const httpServer = http.createServer(app);
app.use(
cors({
origin: "http://localhost:5173",
})
);
app.use(express.json());
app.get("/", (req, res) => {
res.json({
message: "Socket.IO server is running",
});
});
const io = new Server(httpServer, {
cors: {
origin: "http://localhost:5173",
methods: ["GET", "POST"],
},
});
io.on("connection", (socket) => {
console.log("Client connected:", socket.id);
socket.emit("welcome", {
message: "Welcome! You are connected to the server.",
socketId: socket.id,
});
socket.on("say-hello", (data) => {
console.log("Client says:", data.message);
socket.emit("hello-response", {
message: `Hello ${data.name}!`,
});
});
socket.on("disconnect", (reason) => {
console.log("Client disconnected:", socket.id);
console.log("Reason:", reason);
});
});
const PORT = 5000;
httpServer.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
This is because Socket.IO is attached to the actual Node.js HTTP server.
10. Creating the React Client Return to the main project folder:
Create the React application:
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install socket.io-client
Replace src/App.jsx with:
import { useEffect, useState } from "react";
import { io } from "socket.io-client";
const socket = io("http://localhost:5000");
export default function App() {
const [connectionStatus, setConnectionStatus] =
useState("Connecting...");
const [message, setMessage] = useState("");
useEffect(() => {
function handleConnect() {
setConnectionStatus(`Connected: ${socket.id}`);
}
function handleDisconnect() {
setConnectionStatus("Disconnected");
}
function handleWelcome(data) {
setMessage(data.message);
}
function handleHelloResponse(data) {
setMessage(data.message);
}
socket.on("connect", handleConnect);
socket.on("disconnect", handleDisconnect);
socket.on("welcome", handleWelcome);
socket.on("hello-response", handleHelloResponse);
return () => {
socket.off("connect", handleConnect);
socket.off("disconnect", handleDisconnect);
socket.off("welcome", handleWelcome);
socket.off("hello-response", handleHelloResponse);
};
}, []);
function sayHello() {
socket.emit("say-hello", {
name: "MERN Student",
message: "Hello from React",
});
}
return (
<main
style={{
fontFamily: "Arial",
maxWidth: 700,
margin: "50px auto",
}}
>
<h1>Socket.IO Basics</h1>
<p>{connectionStatus}</p>
<button onClick={sayHello}>
Say Hello
</button>
<h2>{message}</h2>
</main>
);
}
Run the frontend in another terminal:
When the browser connects, the backend prints:
Client connected: some-socket-id
The server sends a welcome event, and React displays the message.
When you click the button:
React emits say-hello
↓
Server receives say-hello
↓
Server emits hello-response
↓
React receives hello-response
This is two-way real-time communication.
11. What Is socket.id? Every active Socket.IO connection gets an identifier:
If you open the application in two browser tabs, each tab usually creates a separate connection and receives a separate socket ID.
However, never treat socket.id as a permanent user ID.
It represents one connection It can change after reconnection One user may have multiple browser tabs One user may have multiple devices Use your database user ID as the permanent identity.
Use socket.id only for the current connection.
12. Understanding emit() and on() Consider this client-side code:
socket.emit("send-message", {
text: "Hello class",
});
Send an event named send-message to the server. socket.on("send-message", (data) => {
console.log(data.text);
});
The server can then send another event:
socket.emit("message-received", {
success: true,
});
socket.on("message-received", (data) => {
console.log(data.success);
});
Event names must match exactly.
socket.emit("send-message");
socket.on("sendMessage");
send-message !== sendMessage
13. Sending JavaScript Objects Socket.IO can send normal JavaScript objects.
socket.emit("new-message", {
text: "Hello everyone",
username: "Sufiyan",
createdAt: new Date().toISOString(),
});
You usually do not need to manually use:
The receiver gets the object directly:
socket.on("new-message", (message) => {
console.log(message.text);
console.log(message.username);
});
14. Different Ways to Send Events This is one of the most important Socket.IO concepts.
Send to one connected client socket.emit("event-name", data);
This sends the event only to the current socket.
socket.emit("welcome", {
message: "Welcome to the server",
});
Send to everyone io.emit("event-name", data);
io.emit("announcement", {
text: "The server will restart soon",
});
Every connected client receives it.
Send to everyone except the sender socket.broadcast.emit("event-name", data);
socket.broadcast.emit("user-joined", {
username: "Sufiyan",
});
Everyone receives the event except the user who triggered it.
Send to a specific room io.to(roomId).emit("event-name", data);
io.to("course-node").emit("new-message", {
text: "Welcome Node.js students",
});
Only sockets inside course-node receive it.
15. What Are Acknowledgements? Sometimes emitting an event is not enough.
The client may need to know:
Was the action accepted? Was the message saved? Was the room joined? Did validation fail? Did the server respond? Socket.IO acknowledgements provide request-response behavior for socket events.
Client socket.emit(
"save-message",
{
text: "Hello",
},
(response) => {
console.log(response);
}
);
Server socket.on(
"save-message",
async (payload, acknowledge) => {
const messageId = "msg-123";
acknowledge({
success: true,
messageId,
});
}
);
The server calls the acknowledgement callback:
acknowledge({
success: true,
});
The client receives the response.
16. Acknowledgement Timeout The server might fail to respond.
To prevent the interface from waiting forever, add a timeout:
socket.timeout(5000).emit(
"save-message",
{
text: "Hello",
},
(error, response) => {
if (error) {
console.error(
"Server did not respond within five seconds"
);
return;
}
console.log(response);
}
);
Use acknowledgements when the sender needs direct confirmation.
Joining a protected room Saving a message Submitting a bid Confirming an order action Marking a notification as read
17. What Are Socket.IO Rooms? A room is a server-side channel that sockets can join or leave.
Suppose your application contains three course chats:
course-react
course-node
course-mongodb
React students should not receive Node.js room messages.
Rooms help us target a selected group.
io.on("connection", (socket) => {
socket.on("join-room", (roomId) => {
socket.join(roomId);
});
socket.on("send-room-message", ({ roomId, text }) => {
io.to(roomId).emit("new-room-message", {
senderId: socket.id,
text,
});
});
});
socket.emit("join-room", "course-node");
io.to("course-node").emit("new-room-message", {
text: "Welcome Node.js students",
});
Only members of that room receive it.
18. Sender Included or Excluded? This difference confuses many beginners.
Include the sender io.to(roomId).emit("new-message", message);
Everyone in the room receives the event, including the person who sent it.
Exclude the sender socket.to(roomId).emit("new-message", message);
Everyone in the room receives the event except the sender.
For chat messages, you will usually use:
io.to(roomId).emit("new-message", message);
because the sender should also see the confirmed message.
For typing indicators, you will usually use:
socket.to(roomId).emit("user-typing", data);
because users do not need to receive their own typing status.
19. Rooms Are Controlled by the Server The browser may request to join any room:
socket.emit("join-room", "admin-room");
That does not mean the server should allow it.
Is this user authenticated? Is this user a member of the project? Does this user own this support ticket? Is this user allowed to access this room? Does this role have permission? Never blindly trust a room ID sent by the frontend.
20. Complete Real-Time Chat Backend Now let us build a more practical chat-room server.
Joining rooms Sending messages Online-user lists Typing indicators Acknowledgements Input validation Disconnect handling Install the dependencies:
npm init -y
npm install express socket.io cors
npm install --save-dev nodemon
const express = require("express");
const http = require("http");
const cors = require("cors");
const { randomUUID } = require("crypto");
const { Server } = require("socket.io");
const app = express();
const httpServer = http.createServer(app);
const CLIENT_URL = "http://localhost:5173";
app.use(
cors({
origin: CLIENT_URL,
})
);
app.use(express.json());
app.get("/", (req, res) => {
res.json({
message: "Real-time chat server is running",
});
});
const io = new Server(httpServer, {
cors: {
origin: CLIENT_URL,
methods: ["GET", "POST"],
},
});
/*
Room structure:
roomId -> Map(socketId, username)
*/
const roomUsers = new Map();
function getUsersInRoom(roomId) {
const users = roomUsers.get(roomId);
return users
? Array.from(users.values())
: [];
}
function removeSocketFromRoom(socket, roomId) {
const users = roomUsers.get(roomId);
if (!users) {
return;
}
users.delete(socket.id);
if (users.size === 0) {
roomUsers.delete(roomId);
}
}
io.on("connection", (socket) => {
console.log("Client connected:", socket.id);
socket.on(
"join-room",
({ username, roomId }, acknowledge) => {
const cleanUsername = String(username || "")
.trim()
.slice(0, 30);
const cleanRoomId = String(roomId || "")
.trim()
.slice(0, 50);
if (!cleanUsername || !cleanRoomId) {
acknowledge?.({
success: false,
message: "Username and room are required",
});
return;
}
const previousRoom = socket.data.roomId;
if (previousRoom) {
socket.leave(previousRoom);
removeSocketFromRoom(
socket,
previousRoom
);
io.to(previousRoom).emit(
"room-users",
getUsersInRoom(previousRoom)
);
}
socket.join(cleanRoomId);
socket.data.username = cleanUsername;
socket.data.roomId = cleanRoomId;
if (!roomUsers.has(cleanRoomId)) {
roomUsers.set(
cleanRoomId,
new Map()
);
}
roomUsers
.get(cleanRoomId)
.set(socket.id, cleanUsername);
socket
.to(cleanRoomId)
.emit("system-message", {
id: randomUUID(),
text: `${cleanUsername} joined the room`,
createdAt: new Date().toISOString(),
});
io.to(cleanRoomId).emit(
"room-users",
getUsersInRoom(cleanRoomId)
);
acknowledge?.({
success: true,
username: cleanUsername,
roomId: cleanRoomId,
});
}
);
socket.on(
"send-message",
({ text }, acknowledge) => {
const roomId = socket.data.roomId;
const username = socket.data.username;
const cleanText = String(text || "")
.trim()
.slice(0, 500);
if (!roomId || !username) {
acknowledge?.({
success: false,
message:
"Join a room before sending messages",
});
return;
}
if (!cleanText) {
acknowledge?.({
success: false,
message: "Message cannot be empty",
});
return;
}
const message = {
id: randomUUID(),
text: cleanText,
username,
senderSocketId: socket.id,
roomId,
createdAt: new Date().toISOString(),
};
io.to(roomId).emit(
"new-message",
message
);
acknowledge?.({
success: true,
messageId: message.id,
});
}
);
socket.on("typing-start", () => {
const { roomId, username } =
socket.data;
if (!roomId || !username) {
return;
}
socket.to(roomId).emit(
"user-typing",
{
username,
isTyping: true,
}
);
});
socket.on("typing-stop", () => {
const { roomId, username } =
socket.data;
if (!roomId || !username) {
return;
}
socket.to(roomId).emit(
"user-typing",
{
username,
isTyping: false,
}
);
});
socket.on("disconnect", () => {
const { roomId, username } =
socket.data;
if (roomId) {
removeSocketFromRoom(
socket,
roomId
);
socket
.to(roomId)
.emit("system-message", {
id: randomUUID(),
text:
`${username || "A user"}` +
" left the room",
createdAt: new Date().toISOString(),
});
io.to(roomId).emit(
"room-users",
getUsersInRoom(roomId)
);
}
console.log(
"Client disconnected:",
socket.id
);
});
});
const PORT = process.env.PORT || 5000;
httpServer.listen(PORT, () => {
console.log(
`Server running on http://localhost:${PORT}`
);
});
This project stores connected users in memory.
Learning Local development One Node.js process It is not suitable as permanent storage or for multiple server instances. We will discuss scaling later.
21. Understanding Typing Indicators Typing indicators are temporary events.
socket.emit("typing-start");
The server broadcasts it to other room members:
socket
.to(roomId)
.emit("user-typing", {
username,
isTyping: true,
});
When the user stops typing:
socket.emit("typing-stop");
Do not store every typing event in MongoDB.
Typing status is temporary and becomes useless after a few seconds.
Sockets are ideal for temporary real-time state.
22. Private Messages and User Rooms Suppose a permanent user has the MongoDB ID:
When that user connects, make the socket join:
socket.join("user:65fabc123");
io.on("connection", (socket) => {
const userId = socket.data.userId;
socket.join(`user:${userId}`);
});
Now the server can send a notification to all active tabs and devices belonging to that user:
io.to(`user:${receiverUserId}`)
.emit("private-message", {
text: "You received a new message",
});
This is better than sending to one socket.id.
Two browser tabs A phone A laptop A new socket ID after reconnection A room based on the permanent user ID can target all active connections.
23. Authenticating Socket Connections CORS is not authentication.
CORS only controls which browser origins may attempt a connection.
The server still needs to verify who the user is.
A React client can send a JWT while connecting:
const socket = io(
"http://localhost:5000",
{
auth: {
token:
localStorage.getItem(
"accessToken"
),
},
}
);
The server verifies it using Socket.IO middleware:
const jwt = require("jsonwebtoken");
io.use((socket, next) => {
try {
const token =
socket.handshake.auth.token;
if (!token) {
return next(
new Error(
"Authentication required"
)
);
}
const payload = jwt.verify(
token,
process.env.JWT_SECRET
);
socket.data.userId =
payload.userId;
socket.data.role =
payload.role;
next();
} catch (error) {
next(
new Error(
"Invalid or expired token"
)
);
}
});
The client can handle authentication failure:
socket.on(
"connect_error",
(error) => {
console.error(error.message);
}
);
24. Authentication and Authorization Are Different Is this user allowed to perform this action? A user may be successfully authenticated but still not be allowed to join a project room.
socket.on(
"join-project",
async (
projectId,
acknowledge
) => {
const membership =
await ProjectMember.findOne({
projectId,
userId:
socket.data.userId,
});
if (!membership) {
acknowledge({
success: false,
message: "Access denied",
});
return;
}
socket.join(
`project:${projectId}`
);
acknowledge({
success: true,
});
}
);
Do not accept protected room access only because the frontend sent the correct room name.
25. Saving Messages in MongoDB Sockets deliver messages immediately.
MongoDB stores them permanently.
Socket.IO delivers the message now. MongoDB shows the message again tomorrow. const mongoose = require("mongoose");
const messageSchema =
new mongoose.Schema(
{
roomId: {
type: String,
required: true,
index: true,
},
senderId: {
type:
mongoose.Schema.Types
.ObjectId,
ref: "User",
required: true,
},
text: {
type: String,
required: true,
maxlength: 500,
},
},
{
timestamps: true,
}
);
module.exports =
mongoose.model(
"Message",
messageSchema
);
When receiving a new message, save it before broadcasting it:
socket.on(
"send-message",
async (
{ roomId, text },
acknowledge
) => {
try {
const cleanText =
String(text || "").trim();
if (!cleanText) {
return acknowledge({
success: false,
message: "Empty message",
});
}
/*
Verify here that the user is
allowed to send a message
inside this room.
*/
const savedMessage =
await Message.create({
roomId,
senderId:
socket.data.userId,
text: cleanText,
});
io.to(`room:${roomId}`)
.emit(
"new-message",
savedMessage
);
acknowledge({
success: true,
messageId:
savedMessage._id,
});
} catch (error) {
acknowledge({
success: false,
message:
"Unable to save message",
});
}
}
);
Because broadcasting an unsaved message can create inconsistency.
Message broadcast successfully
↓
Database save fails
Users temporarily see a message that does not exist in permanent history.
Saving first provides a more reliable flow:
Validate
↓
Save in MongoDB
↓
Broadcast saved message
↓
Send acknowledgement
26. Loading Old Messages with REST Do not use Socket.IO for everything.
Use a REST endpoint for message history:
app.get(
"/api/rooms/:roomId/messages",
async (req, res) => {
const messages =
await Message.find({
roomId:
req.params.roomId,
})
.sort({
createdAt: -1,
})
.limit(50)
.lean();
res.json({
messages:
messages.reverse(),
});
}
);
React can load existing messages:
const response = await fetch(
`/api/rooms/${roomId}/messages`
);
const data =
await response.json();
setMessages(data.messages);
Then Socket.IO delivers new messages:
socket.on(
"new-message",
(message) => {
setMessages((current) => [
...current,
message,
]);
}
);
This creates a clean division:
REST → Existing history
Socket.IO → New live events
27. Handling Disconnects and Reconnection Real users do not have perfect internet.
Lose Wi-Fi Switch mobile networks Close a laptop lid Move between network towers Put an application in the background Temporary disconnections are normal.
The Socket.IO client can automatically attempt to reconnect.
Listen for connection changes:
socket.on("connect", () => {
console.log(
"Connected:",
socket.id
);
});
socket.on(
"disconnect",
(reason) => {
console.log(
"Disconnected:",
reason
);
}
);
socket.io.on(
"reconnect_attempt",
(attempt) => {
console.log(
"Reconnection attempt:",
attempt
);
}
);
Display the state to the user:
<p>
{socket.connected
? "Connected"
: "Reconnecting..."}
</p>
28. Can Messages Be Missed? This is an important production concept.
Suppose the server emits:
io.to(userRoom).emit(
"important-notification",
data
);
If the user is disconnected at that moment, they may miss the event.
Socket.IO is a real-time transport system. It is not automatically a permanent message database.
Important events should also be stored.
Chat messages Order updates Job application status Payment status Notifications Support replies After reconnection, the client can request anything it missed.
1. Save every important message with a permanent ID. 2. Broadcast the saved message. 3. Track the latest message ID on the client. 4. After reconnection, fetch messages created after that ID. 5. Ignore duplicate IDs in the client.
29. Avoiding Duplicate Messages A message may appear twice because of:
Reconnection recovery Manual resynchronization Duplicate event handling Multiple listeners Retry logic setMessages((current) => {
const alreadyExists =
current.some(
(message) =>
message._id ===
incomingMessage._id
);
if (alreadyExists) {
return current;
}
return [
...current,
incomingMessage,
];
});
This makes the client update idempotent.
Processing the same message twice produces the same final state as processing it once.
30. React Listener Cleanup A common React mistake is registering listeners repeatedly.
Incorrect useEffect(() => {
socket.on(
"new-message",
(message) => {
setMessages((current) => [
...current,
message,
]);
}
);
});
There is no dependency array and no cleanup.
The listener may be added after every render.
One event may then update the interface multiple times.
Correct useEffect(() => {
function handleNewMessage(
message
) {
setMessages((current) => [
...current,
message,
]);
}
socket.on(
"new-message",
handleNewMessage
);
return () => {
socket.off(
"new-message",
handleNewMessage
);
};
}, []);
Always remove the same named function that was added.
Duplicate UI updates Memory leaks Repeated messages Strange development behavior
31. Socket Event Validation Socket event payloads are external input.
Treat them exactly like HTTP request bodies.
socket.on(
"send-message",
(payload) => {
/*
Do not directly trust
payload.text.
*/
}
);
socket.on(
"send-message",
(
payload,
acknowledge
) => {
const text =
String(
payload?.text || ""
).trim();
if (
!text ||
text.length > 500
) {
acknowledge({
success: false,
message:
"Invalid message",
});
return;
}
/*
Continue with authorized
database operation.
*/
}
);
Important security practices include:
Authenticate the connection Authorize every protected room Validate every payload Limit message length Limit event frequency Escape or safely render user content Avoid dangerouslySetInnerHTML Restrict allowed origins Use HTTPS in production Never broadcast sensitive information Never treat socket.id as account identity
32. Basic Event Rate Limiting A malicious user can emit thousands of events.
For a basic single-server example:
const eventCounters =
new Map();
function canSendMessage(
socketId
) {
const now = Date.now();
const current =
eventCounters.get(
socketId
) || {
count: 0,
resetAt:
now + 10_000,
};
if (
now > current.resetAt
) {
current.count = 0;
current.resetAt =
now + 10_000;
}
current.count += 1;
eventCounters.set(
socketId,
current
);
return current.count <= 20;
}
socket.on(
"send-message",
(
payload,
acknowledge
) => {
if (
!canSendMessage(
socket.id
)
) {
acknowledge({
success: false,
message:
"Too many messages",
});
return;
}
/*
Continue processing.
*/
}
);
For multiple servers, an in-memory limiter is not shared.
Use Redis or another centralized store for distributed rate limiting.
33. File Uploads Should Use HTTP A chat application may support images and documents.
Do not necessarily send large files directly through socket events.
React uploads file through HTTP
↓
Backend stores file in Cloudinary or object storage
↓
Backend returns saved file URL and metadata
↓
React emits a socket message containing the metadata
socket.emit("send-message", {
type: "file",
fileUrl:
"https://example.com/file.pdf",
fileName: "assignment.pdf",
});
Use sockets to notify users about the saved file, not as a replacement for your complete upload infrastructure.
34. Running Socket.IO with Docker When the Node.js application runs inside Docker, Docker normally manages the container lifecycle.
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 5000
CMD ["node", "server.js"]
services:
socket-api:
build: .
container_name:
socket-api
restart:
unless-stopped
ports:
- "5000:5000"
environment:
- CLIENT_URL=https://app.example.com
For a simple one-process container, PM2 is usually unnecessary.
Background execution Container restart Logs Server-reboot recovery Resource isolation
35. Running Socket.IO Directly on EC2 If Node.js is running directly on EC2 without Docker, PM2 can manage it:
pm2 start server.js \
--name socket-api
Configure reboot startup:
However, do not immediately run:
pm2 start server.js -i max
for Socket.IO without understanding multi-process communication and sticky sessions.
Each PM2 cluster worker is a separate process with separate memory.
36. Nginx Reverse Proxy for Socket.IO A production application may place Nginx in front of Node.js.
server {
listen 80;
server_name api.example.com;
location /socket.io/ {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For
$proxy_add_x_forwarded_for;
}
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
The important upgrade headers are:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
They allow the HTTP connection to upgrade to WebSocket when needed.
In production, use HTTPS so clients connect securely.
37. The Scaling Problem Suppose one Node.js server is not enough.
You run three Socket.IO servers:
Load Balancer
|
|-- Socket Server A
|
|-- Socket Server B
|
`-- Socket Server C
User 1 may be connected to Server A.
User 2 may be connected to Server B.
io.to(roomId).emit(
"new-message",
message
);
the default in-memory adapter only knows about sockets connected to Server A.
It cannot automatically reach users connected to Server B and Server C.
This is why multi-server Socket.IO deployments need a shared adapter.
38. Redis Adapter The Redis adapter allows Socket.IO servers to exchange broadcasting information.
npm install \
@socket.io/redis-adapter \
redis
const {
createClient,
} = require("redis");
const {
createAdapter,
} = require(
"@socket.io/redis-adapter"
);
async function configureRedisAdapter(
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
)
);
}
Then call it before starting the server:
async function startServer() {
await configureRedisAdapter(io);
httpServer.listen(5000, () => {
console.log(
"Server is running"
);
});
}
startServer();
Now events published by one Socket.IO server can reach sockets connected to other instances.
39. What Are Sticky Sessions? Socket.IO may use HTTP long-polling before upgrading to WebSocket.
During that session, multiple HTTP requests may belong to the same socket connection.
Those requests need to reach the same server instance.
Without sticky sessions, one request may reach Server A and another may reach Server B.
Server B does not know the session created on Server A, which can produce errors such as:
For a multi-server setup, you usually need:
Sticky sessions Redis adapter Shared authentication or session state External MongoDB or PostgreSQL storage Distributed rate limiting The Redis adapter and sticky sessions solve different problems.
Redis adapter:
Shares room broadcasts between servers
Sticky sessions:
Keeps one polling session on the correct server
40. Common Socket.IO Mistakes
The socket does not connect Wrong frontend URL Wrong backend port Backend is not running CORS configuration mismatch Nginx configuration issue Socket.IO path mismatch Browser Network tab Backend logs Frontend URL Backend URL Environment variables Proxy configuration
Events fire twice in React Listener registered more than once Missing effect dependency array Missing socket.off() cleanup React development Strict Mode exposing bad listener management Use named handlers and proper cleanup.
Everyone receives a private event io.emit("private-event", data);
Instead, emit to a selected room:
io.to(`user:${userId}`)
.emit(
"private-event",
data
);
The sender does not receive their message socket.to(roomId).emit(
"new-message",
message
);
This excludes the sender.
io.to(roomId).emit(
"new-message",
message
);
Old messages disappear Socket events are temporary.
Save important messages in MongoDB and load history using REST.
Authentication works in REST but not in sockets Socket.IO has a separate connection handshake and middleware flow.
io.use((socket, next) => {
// Verify token
});
Rooms stop working after adding another server The default adapter stores room membership inside one process.
Use a Redis-compatible adapter when scaling.
Memory usage keeps growing Listeners are never removed User maps are not cleaned Messages are stored forever in memory Payload sizes are unlimited Disconnected sockets are not removed from custom data structures
41. Practical Classroom Exercises
Exercise 1: Live Connection Counter Show the number of active connections.
let connectedUsers = 0;
io.on("connection", (socket) => {
connectedUsers += 1;
io.emit(
"connected-count",
connectedUsers
);
socket.on("disconnect", () => {
connectedUsers -= 1;
io.emit(
"connected-count",
connectedUsers
);
});
});
socket.on(
"connected-count",
(count) => {
setConnectedUsers(count);
}
);
Exercise 2: Live Announcement Create an HTTP route that broadcasts a socket event:
app.post(
"/api/announce",
(req, res) => {
io.emit("announcement", {
text: req.body.text,
createdAt:
new Date().toISOString(),
});
res.json({
success: true,
});
}
);
This demonstrates that REST and sockets can work together.
Admin sends HTTP request
↓
Express handles request
↓
Socket.IO broadcasts event
↓
All connected clients update
Exercise 3: Course Rooms course:react
course:node
course:mongodb
Students joining course:react must not receive messages from course:node.
Exercise 4: Private Notifications Then create an Express route that sends a notification to one user room.
42. Final Student Project Build a real-time customer-support system.
Customers should be able to open support tickets and communicate with support agents in real time.
JWT authentication Customer and agent roles One room for each support ticket Server-side room authorization MongoDB message persistence Live messages Typing indicators Online and offline indicators Message acknowledgements Unread-message counts Reconnection handling Message-history synchronization Input validation Event rate limiting Docker deployment File attachments through HTTP uploads Delivered and read receipts BullMQ email notifications Redis adapter Two Node.js server instances Nginx or AWS load balancing Sticky sessions Admin dashboard showing active connections
43. Common Interview Questions
What problem do sockets solve? Sockets provide persistent two-way communication so that the server can push updates without waiting for another client request.
Does Socket.IO replace REST APIs? No. REST is useful for CRUD and stored data. Socket.IO is useful for immediate events and live updates.
Is Socket.IO the same as WebSocket? No. WebSocket is a protocol. Socket.IO is a higher-level library with its own protocol and additional features.
What does socket.emit() do? It sends a named event with optional data.
What does socket.on() do? It registers a listener for a named event.
What is the difference between io.emit() and socket.emit()? io.emit() sends to every connected client.
socket.emit() sends only to one socket.
What does socket.broadcast.emit() do? It sends an event to all connected clients except the sender.
What is a room? A room is a server-side channel used to group sockets and send events to selected clients.
Are rooms automatically stored in MongoDB? No. The default Socket.IO adapter stores current room information in server memory.
What is an acknowledgement? An acknowledgement is a callback response confirming whether an emitted event was handled successfully.
Why should chat messages be stored in MongoDB? Sockets provide immediate delivery, while MongoDB provides permanent history and recovery.
Can socket.id be used as a permanent user ID? No. It represents one current connection and can change after reconnection.
How do we authenticate sockets? Send a JWT inside the connection authentication data and verify it using io.use() middleware.
Why is Redis used with Socket.IO? Redis allows multiple Socket.IO server instances to exchange broadcast and room events.
Why are sticky sessions needed? When HTTP long-polling is used across multiple servers, all requests belonging to one socket session must reach the same server.
Why should React socket listeners be removed? To prevent duplicate listeners, repeated updates and memory leaks.
44. Socket.IO Cheat Sheet const httpServer =
http.createServer(app);
const io =
new Server(httpServer, {
cors: {
origin: CLIENT_URL,
},
});
io.on(
"connection",
(socket) => {
console.log(socket.id);
}
);
const socket =
io(SERVER_URL);
socket.emit(
"event-name",
data
);
socket.on(
"event-name",
(data) => {}
);
io.emit(
"event-name",
data
);
Send to everyone except sender:
socket.broadcast.emit(
"event-name",
data
);
Send to room including sender:
io.to(roomId).emit(
"event-name",
data
);
Send to room excluding sender:
socket.to(roomId).emit(
"event-name",
data
);
socket.emit(
"event-name",
data,
(response) => {}
);
Acknowledgement with timeout:
socket.timeout(5000).emit(
"event-name",
data,
(
error,
response
) => {}
);
Authenticate connections:
io.use(
(socket, next) => {
const token =
socket.handshake.auth
.token;
/*
Verify token here.
*/
next();
}
);
Handle connection errors:
socket.on(
"connect_error",
(error) => {
console.error(
error.message
);
}
);
socket.on(
"disconnect",
(reason) => {
console.log(reason);
}
);
Conclusion The easiest way to understand sockets is to remember that they create a live communication channel.
Client asks
Server responds
Client and server stay connected
Either side can emit an event
A production MERN application commonly uses both.
Signup and login Loading chat history Uploading files CRUD operations Fetching stored data New messages Typing indicators Online presence Live notifications Real-time order updates Application-status changes Live dashboards The one sentence every student should remember is:
REST loads the current data. Sockets deliver changes in real time. Once this distinction is clear, Socket.IO becomes much easier to understand.
HIRINGMINE CAREER SIGNAL This writing is proof of expertise. Explore the author’s verified skills, projects and availability—or start a professional conversation.
View career profile Hire this authorMS WRITTEN BY Muhammad Sufiyan Software Engineer. Writing about practical work and career growth.
View profile