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.
The read receipt system gives senders confidence that their messages are reaching recipients:
| Stage | Status | What It Means | When It Happens |
|---|---|---|---|
| Sent | ✓ | Message in database | Immediately after server receives message |
| Delivered | ✓✓ | Message received by client | Client receives message event and acknowledges |
| Read | Blue ✓✓ | User viewed the message | User opens the message or scrolls to view it |
Read Receipt Status Flow
The message_recipients table tracks the receipt status:
Read Receipt Schema
When the server successfully saves a message to the database.
Sent Stage
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 },
})
})
Delivered Stage
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,
},
})
})
Read Stage
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,
})),
})
}
})