Facebook Pixel

Chat Message Persistence and History Loading and Paginating Messages

Learn how to persist chat messages to MongoDB and load message history with pagination ensuring users can scroll back through previous conversations.

Chat Message Persistence and History

Real-time messages need to be persisted so users can load history when they reopen a chat. This guide covers persistence and pagination.

REST API for Message History

// src/routes/chat.js const router = require("express").Router(); const Message = require("../models/Message"); const userAuth = require("../middlewares/auth"); // Get chat list (all conversations) router.get("/", userAuth, asyncHandler(async (req, res) => { const conversations = await Conversation.find({ participants: req.user._id }) .sort({ lastMessageAt: -1 }) .populate("participants", "firstName lastName photoUrl") .populate("lastMessage"); const data = conversations.map((conv) => ({ _id: conv._id, otherUser: conv.participants.find( (p) => p._id.toString() !== req.user._id.toString() ), lastMessage: conv.lastMessage, lastMessageAt: conv.lastMessageAt, unreadCount: conv.unreadCount.get(req.user._id.toString()) || 0 })); res.json({ data }); })); // Get message history with a specific user router.get("/:targetUserId", userAuth, asyncHandler(async (req, res) => { const { targetUserId } = req.params; const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 50; const skip = (page - 1) * limit; const messages = await Message.find({ $or: [ { senderId: req.user._id, receiverId: targetUserId }, { senderId: targetUserId, receiverId: req.user._id } ] }) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) .populate("senderId", "firstName lastName photoUrl"); const total = await Message.countDocuments({ $or: [ { senderId: req.user._id, receiverId: targetUserId }, { senderId: targetUserId, receiverId: req.user._id } ] }); res.json({ data: messages.reverse(), // Oldest first pagination: { page, limit, total, totalPages: Math.ceil(total / limit), hasMore: skip + limit < total } }); })); // Mark messages as read router.patch("/read/:targetUserId", userAuth, asyncHandler(async (req, res) => { const { targetUserId } = req.params; await Message.updateMany( { senderId: targetUserId, receiverId: req.user._id, read: false }, { $set: { read: true, readAt: new Date() } } ); await Conversation.findOneAndUpdate( { participants: { $all: [req.user._id, targetUserId] } }, { $set: { [`unreadCount.${req.user._id}`]: 0 } } ); res.json({ message: "Messages marked as read" }); })); module.exports = router;

Frontend: Loading History with Infinite Scroll

const ChatWindow = ({ targetUserId }) => { const [messages, setMessages] = useState([]); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const messagesEndRef = useRef(null); const messagesStartRef = useRef(null); // Load initial messages useEffect(() => { setMessages([]); setPage(1); setHasMore(true); loadMessages(1, true); }, [targetUserId]); // Load more messages (older) const loadMore = async () => { if (loading || !hasMore) return; const prevScrollHeight = messagesStartRef.current?.scrollHeight; await loadMessages(page + 1, false); // Maintain scroll position when loading older messages if (messagesStartRef.current) { messagesStartRef.current.scrollTop = messagesStartRef.current.scrollHeight - prevScrollHeight; } }; const loadMessages = async (pageNum, append) => { setLoading(true); const res = await fetch( `/api/chat/${targetUserId}?page=${pageNum}&limit=50`, { credentials: "include" } ); const data = await res.json(); if (append) { setMessages((prev) => [...data.data, ...prev]); } else { setMessages(data.data); } setHasMore(data.pagination.hasMore); setPage(pageNum); setLoading(false); }; // Handle scroll to top (load older messages) const handleScroll = (e) => { if (e.target.scrollTop === 0 && hasMore && !loading) { loadMore(); } }; // Socket.io listener for new messages useEffect(() => { socket.emit("join:chat", { targetUserId }); const handleMessage = (message) => { if (message.senderId === targetUserId || message.receiverId === targetUserId) { setMessages((prev) => [...prev, message]); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); if (message.senderId === targetUserId) { socket.emit("messages:read", { targetUserId }); } } }; socket.on("message:received", handleMessage); return () => socket.off("message:received", handleMessage); }, [targetUserId]); return ( <div className="messages-container" onScroll={handleScroll} ref={messagesStartRef}> {loading && <div className="loading">Loading messages...</div>} {messages.map((msg) => ( <MessageBubble key={msg._id} message={msg} isOwn={msg.senderId._id === currentUserId} /> ))} <div ref={messagesEndRef} /> </div> ); };

Message Persistence in Socket.io

// In the message:send handler socket.on("message:send", async ({ targetUserId, text }, callback) => { // Save to database const message = await Message.create({ senderId: socket.userId, receiverId: targetUserId, text }); // Update conversation await Conversation.findOneAndUpdate( { participants: { $all: [socket.userId, targetUserId] } }, { $set: { lastMessage: message._id, lastMessageAt: message.createdAt }, $inc: { [`unreadCount.${targetUserId}`]: 1 } }, { upsert: true } ); // Broadcast const roomId = getRoomId(socket.userId, targetUserId); io.to(roomId).emit("message:received", message); callback({ success: true, message }); });

The Takeaway

Message persistence involves: saving every message to MongoDB on send, updating the Conversation model with lastMessage and unreadCount, providing REST endpoints for loading history (with pagination and skip/limit), implementing infinite scroll on the frontend (load older messages when scrolling to top), and maintaining scroll position when older messages are loaded. Real-time delivery via Socket.io handles new messages, REST handles history.

REST endpoint GET /api/chat/:targetUserId?page=1&limit=50. Query Message with $or for both directions, sort by createdAt -1, skip (page-1)*limit, limit. Return messages reversed (oldest first) with pagination metadata (total, totalPages, hasMore). Use compound index on (senderId, receiverId, createdAt) for efficiency.

On scroll to top, load the next page of older messages and prepend to the list. Maintain scroll position by saving the previous scrollHeight and setting scrollTop after new messages are added. Stop loading when hasMore is false.

In the message:send handler, save to MongoDB with Message.create(), update the Conversation model (lastMessage, lastMessageAt, unreadCount), then broadcast to the room via io.to(roomId).emit('message:received', message). Use a callback acknowledgment to confirm success.

GET /api/chat (chat list with unread counts), GET /api/chat/:targetUserId (message history with pagination), PATCH /api/chat/read/:targetUserId (mark messages as read). Use Socket.io for real-time new messages and typing indicators. REST handles initial load, Socket.io handles live updates.

Before loading, save the current scrollHeight. After new messages are prepended, set scrollTop to (new scrollHeight - old scrollHeight). This keeps the user's view position stable instead of jumping to the top when older messages are loaded.

Ready to master Node.js completely?

Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course to dive deeper with high-quality video tutorials, solve interview questions, and a premium community.

Please Login.
Please Login.
Please Login.
Please Login.