Projects
Jan 5, 2026

Read Receipts System

Three-stage message delivery tracking: Sent, Delivered, Read

This article explains the three-stage read receipt system that provides users with visibility into message delivery status: Sent, Delivered, and Read.

Overview

The read receipt system gives senders confidence that their messages are reaching recipients:

StageStatusWhat It MeansWhen It Happens
SentMessage in databaseImmediately after server receives message
Delivered✓✓Message received by clientClient receives message event and acknowledges
ReadBlue ✓✓User viewed the messageUser opens the message or scrolls to view it

Architecture

Read Receipt Status Flow

Database Schema for Receipts

The message_recipients table tracks the receipt status:

Read Receipt Schema

Message Sent

When the server successfully saves a message to the database.

Flow Diagram

Sent Stage

Implementation

Client sends the message via HTTP.

const sendMessage = async (receiverId: number, content: string, hash: string) => {
  const response = await $api('/api/messages/send/personal', {
    method: 'POST',
    body: { receiverId, content, hash },
  })
  return response
}

The backend, immediately publishes the message to RabbitMQ.

export async function sendPersonalMessage(context: any, dto: SendPersonalMessageDto) {
  const senderId = context.user.id

  await this.rabbitmqService.publishToIncoming('personal.message', {
    type: 'MESSAGE_SEND',
    payload: {
      senderId,
      receiverId: dto.receiverId,
      content: dto.content,
      hash: dto.hash,
    },
  })

  return { success: true }
}

The chat worker pulls the messages from RabbitMQ, persists, and publishes it back to RabbitMQ.

consumer.on('personal.incoming.message', async event => {
  const { hash, content, senderId, receiverId } = event.payload

  const message = await messageRepository.save({
    content,
    sender: { id: senderId },
  })

  await messageRecipientRepository.save({
    message: { id: message.id },
    receiver: { id: receiverId },
    status: MessageStatus.SENT,
  })

  await rabbitmq.publishToOutgoing(senderId.toString(), {
    event: 'PERSONAL_MESSAGE_SENT',
    data: { hash, messageId: message.id, createdAt: message.createdAt },
  })

  await rabbitmq.publishToOutgoing(receiverId.toString(), {
    event: 'PERSONAL_MESSAGE_RECEIVE',
    data: { messageId: message.id, content, senderId, createdAt: message.createdAt },
  })
})

Message Delivered

Flow Diagram

Delivered Stage

Implementation

When the receiver gets the message, they emit the DELIVERED event via HTTP.

const handleMessageReceive = async (payload: SocketEventPayloads.Personal.OnMessage) => {
  const message: IMessage = {
    id: payload.messageId,
    content: payload.content,
    createdAt: payload.createdAt,
    senderId: payload.senderId,
    status: MessageStatus.DELIVERED,
  }

  // Add to UI
  pushMessage(payload.senderId, message)

  try {
    await handleDelivered(message.id, authUser.value.id, payload.senderId)
  } catch (error) {
    console.error('Error sending delivered status:', error)
  }
}

The backend, immediately publishes the message to RabbitMQ.

func (s *MessageService) MarkMessageAsDelivered(ctx context.Context, messageID int64, receiverID, senderID int64) error {
  return s.rabbitmqService.PublishToIncoming("personal.delivered", RabbitMQMessage{
    Type: "STATUS_DELIVERED",
    Payload: map[string]any{
      "messageId":  messageID,
      "receiverId": receiverID,
      "senderId":   senderID,
    },
  })
}

The worker updates the status in the database and forwards the event to sender's socket.

consumer.on('personal.delivered', async event => {
  const { messageId, receiverId, senderId } = event.payload

  await messageRecipientRepository.update(
    {
      message: { id: messageId },
      receiver: { id: receiverId },
    },
    { status: MessageStatus.DELIVERED },
  )

  await rabbitmq.publishToOutgoing(senderId.toString(), {
    event: 'PERSONAL_MESSAGE_DELIVERED',
    data: {
      messageId,
      receiverId,
      status: MessageStatus.DELIVERED,
    },
  })
})

Message Read

Flow Diagram

Read Stage

Implementation

When the receiver opens the chat to read the messages, they emit the READ event via HTTP.

watchEffect(() => {
  const messages = messagesData.value
  if (!messages) return

  const payloads: Array<{
    messageId: number
    senderId: number
    receiverId: number
  }> = []

  messages.forEach(message => {
    if (message.senderId === authUser.value.id || message.status === MessageStatus.READ) {
      return
    }

    payloads.push({
      messageId: message.id,
      senderId: receiverId,
      receiverId: authUser.value.id,
    })

    // Update message status on UI
    updateMessageStatus(receiverId, message.id, MessageStatus.READ)
  })

  if (payloads.length > 0) {
    handleRead(payloads).catch(error => {
      console.error('Error sending read status:', error)
    })
  }
})

The backend, immediately publishes the message to RabbitMQ.

type MarkReadPayload struct {
  MessageID int64
  SenderId  int64
  ReceiverId int64
}

func (s *MessageService) MarkMessagesAsRead(ctx context.Context, receiverID int64, payloads []MarkReadPayload) error {
  message := RabbitMQMessage{
    Type: "STATUS_READ",
    Payload: map[string]any{
      "messages": payloads,
      "receiverId": receiverID,
    },
  }
  return s.rabbitmqService.PublishToIncoming("personal.read", message)
}

The worker updates the status in the database and forwards the event to sender's socket.

consumer.on('personal.read', async event => {
  const payloads = event.payload

  const promises = payloads.map(p =>
    messageRecipientRepository.update(
      {
        message: { id: p.messageId },
        receiver: { id: p.receiverId },
      },
      { status: MessageStatus.READ },
    ),
  )

  await Promise.all(promises)

  const bySender = new Map<number, any[]>()
  payloads.forEach(p => {
    if (!bySender.has(p.senderId)) {
      bySender.set(p.senderId, [])
    }
    bySender.get(p.senderId)!.push(p)
  })

  for (const [senderId, messages] of bySender) {
    await rabbitmq.publishToOutgoing(senderId.toString(), {
      event: 'PERSONAL_MESSAGE_READ',
      data: messages.map(m => ({
        messageId: m.messageId,
        receiverId: m.receiverId,
        status: MessageStatus.READ,
      })),
    })
  }
})