While building my small hobby project, which has a chat feature, I faced a simple issue.
How do we jump to a specific message in the chat if the user clicks on the message being replied to? 🤯
Actually, I am new to the backend architecture of chat-based applications and I don't know the best practices. I started with a standard page and offset-based pagination for my chat API.
My first attempt: Offset pagination
As an optimistic developer, I decided to stick with my existing offset-based pagination system. My API looked something like this:
GET /api/channel/{channelId}/messages?limit=50&offset=100
I thought I could simply calculate the offset of the target message and fetch the surrounding messages. The implementation looked something like this:
async function getMessageContext(messageId, limit = 50) {
const targetMessage = await Message.findByPk(messageId);
const messageCount = await Message.count({ where: { channelId: targetMessage.channelId } });
const offset = messageCount - targetMessage.id;
return Message.findAll({
where: { channelId: targetMessage.channelId },
limit,
offset: Math.max(offset - Math.floor(limit / 2), 0),
order: [['id', 'DESC']]
});
}
But the problem is that it is slow and requires more database queries. So, this is not a good method to proceed with. And after so much of stackoverflowing....😁
Cursor pagination
Instead of using offsets, which are position-based and can change, I use a fixed point—a cursor—as my reference. In my case, I could use the message's primary key ID as the cursor. And my API endpoint looks like this:
GET /api/channel/{channelId}/messages?cursor=456&limit=50
async listChatMessages(userId, channelId, queryFilter) {
const { cursor = null, items } = queryFilter;
const limit = +items;
const where: any = {};
where[Op.and] = [];
where[Op.and].push({
channelId,
});
if (cursor) {
where[Op.and].push({
id: {
[Op.lt]: cursor,
},
});
}
const messages = await Message.findAll({
attributes: ['id', 'message', 'createdAt'],
where,
limit,
order: [['createdAt', 'DESC']],
raw: true
});
const nextCursor = messages.length > 0 ? messages[messages.length - 1].id : null;
return {
messages,
nextCursor
};
}
But there's one issue, how will I know there is no next cursor and it's the end of the messages?
To handle this, I use a "lookahead" approach by fetching one extra message beyond the item limit to see if more messages exist and remove that extra message before sending the response.
async listChatMessages(userId, channelId, queryFilter) {
const { cursor = null, items } = queryFilter;
const limit = +items;
const where: any = {};
where[Op.and] = [];
where[Op.and].push({
channelId,
});
if (cursor) {
where[Op.and].push({
id: {
[Op.lte]: cursor,
},
});
}
const messages = await Message.findAll({
attributes: ['id', 'message', 'createdAt'],
where,
limit: limit + 1,
order: [['createdAt', 'DESC']],
raw: true
});
const hasMore = messages.length > limit;
const nextCursor = hasMore ? messages[messages.length - 1].id : null;
if (hasMore) {
messages.pop();
}
return {
messages,
hasMore,
nextCursor
};
}
Voilà! Solution ready
Now I can easily jump to my reply message or any message using its message ID as the cursor.
Lessons learned
Offset pagination, while simple, can be problematic in highly dynamic datasets.
Cursor-based pagination provides consistency and performs better for use cases like this.
The choice of pagination method can greatly affect user experience, especially in features like "jump to message."