How I implemented the "Jump to Message" feature in my Chat API

·

3 min read

How I implemented the "Jump to Message" feature in my Chat API

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."