- Socket.IO
- Express.js
- MongoDB
- React
- Real-Time
- MERN
Building a Real-Time Chat App with Socket.IO, Express and MongoDB
A real-time chat app on the MERN stack needs four things: authenticated Socket.IO connections, one room per conversation, a save-then-emit flow with acknowledgements, and paginated message history from MongoDB. This is the structure I used for PingChat.
The architecture in one paragraph
Express serves the REST API (conversations, message history, uploads). Socket.IO runs on the same HTTP server and handles only live events: joining conversations, sending messages, typing indicators. MongoDB stores every message. The React client loads history over REST once, then listens on the socket for anything new.
| Concern | Where it lives |
|---|---|
| Sign-in, sessions | Clerk (token verified on every socket) |
| Live delivery | Socket.IO rooms |
| Message history | MongoDB via Mongoose, REST endpoint |
| Client state | Zustand store |
Step 1: Share one server between Express and Socket.IO
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
const app = express();
const server = createServer(app);
export const io = new Server(server, {
cors: { origin: process.env.CLIENT_URL, credentials: true },
});
server.listen(process.env.PORT ?? 4000);
Calling app.listen() would create a second server that Socket.IO knows nothing about. Create the HTTP server yourself and hand it to both.
Step 2: Authenticate the socket, not just the page
A logged-in page does not mean a trusted socket. Verify a token during the handshake and keep the user id on socket.data:
import { verifyToken } from "@clerk/backend";
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token as string;
const { sub } = await verifyToken(token, { secretKey: process.env.CLERK_SECRET_KEY! });
socket.data.userId = sub;
next();
} catch {
next(new Error("unauthorized"));
}
});
On the client, pass the token as a function so reconnects always send a fresh one:
import { io } from "socket.io-client";
export const socket = io(import.meta.env.VITE_API_URL, {
autoConnect: false,
auth: async (cb) => cb({ token: await getToken() }),
});
Step 3: One room per conversation
Rooms keep broadcasting cheap and private. Check membership in the database before joining, because the client can send any id it likes:
io.on("connection", (socket) => {
socket.on("conversation:join", async (conversationId: string, ack) => {
const member = await Conversation.exists({ _id: conversationId, members: socket.data.userId });
if (!member) return ack({ ok: false });
socket.join(`conv:${conversationId}`);
ack({ ok: true });
});
});
Step 4: Save, then emit, then acknowledge
socket.on("message:send", async ({ conversationId, text, clientId }, ack) => {
if (!socket.rooms.has(`conv:${conversationId}`)) return ack({ ok: false, error: "not a member" });
const message = await Message.create({ conversation: conversationId, sender: socket.data.userId, text });
socket.to(`conv:${conversationId}`).emit("message:new", message);
ack({ ok: true, clientId, message });
});
Three details matter here:
- Save first. If MongoDB rejects the write, nobody receives a message that does not exist.
socket.to(...)skips the sender. The sender gets the saved message back through the acknowledgement instead.clientIdlinks the optimistic copy on the sender's screen to the saved document, so the UI can swap one for the other.
Step 5: Optimistic UI with Zustand
The sender should see the message instantly, marked as pending until the server confirms it:
send: (conversationId, text) => {
const clientId = crypto.randomUUID();
set((s) => ({ messages: [...s.messages, { clientId, text, status: "pending" }] }));
socket.emit("message:send", { conversationId, text, clientId }, (res) => {
set((s) => ({
messages: s.messages.map((m) =>
m.clientId === clientId ? (res.ok ? { ...res.message, status: "sent" } : { ...m, status: "failed" }) : m,
),
}));
});
},
Step 6: Paginate history by cursor, not by page number
New messages arrive constantly, so skip-based pages shift under the user. Use the oldest loaded timestamp as a cursor, backed by a compound index:
messageSchema.index({ conversation: 1, createdAt: -1 });
app.get("/api/conversations/:id/messages", requireAuth, async (req, res) => {
const before = req.query.before ? new Date(String(req.query.before)) : new Date();
const messages = await Message.find({ conversation: req.params.id, createdAt: { $lt: before } })
.sort({ createdAt: -1 })
.limit(30);
res.json(messages.reverse());
});
Production checklist
- Scale out with the Redis adapter (
@socket.io/redis-adapter) once you run more than one server instance. - Rate-limit
message:sendper socket so one client cannot flood a room. - Re-join rooms on reconnect. Listen for the client
connectevent and join active conversations again. - Upload media over REST, not the socket. PingChat sends images through Multer to ImageKit and only emits the resulting URL.
- Make the UI accessible. PingChat uses React Aria Components so the message list and composer work with a keyboard and screen reader.
Key takeaways
- One HTTP server, shared by Express and Socket.IO.
- Verify a token on every socket connection and every room join.
- Save to MongoDB, then emit, then acknowledge.
- Load history over REST with cursor pagination.
Need real-time features in your app? I build them as a freelance MERN stack developer.
Frequently asked questions
How do you build a real-time chat app with Socket.IO and MongoDB?
Run Socket.IO on the same HTTP server as Express, authenticate each socket in an io.use middleware, put each conversation in its own room, save every message to MongoDB first and then emit it to the room, and load older history over a paginated REST endpoint.
Should chat messages be saved before or after emitting them?
Save first, then emit. If the database write fails, nobody sees a message that does not exist, and the saved document gives every client the same id and timestamp.
How do you authenticate Socket.IO connections?
Send a token in the handshake auth payload from the client, verify it in an io.use middleware on the server, store the user id on socket.data, and reject the connection with an error if verification fails.
Is Socket.IO good for production chat apps?
Yes. Socket.IO adds rooms, acknowledgements, automatic reconnection and fallbacks on top of WebSockets. For more than one server instance, add the Redis adapter so events reach sockets connected to other instances.