Messages in the WhatsApp Chat application follow a defined lifecycle, progressing through five statuses to simulate real-world message delivery. This page explains each status, the progression system, and how it’s implemented.
Message status types
Every message has a status field that tracks its delivery state:
// lib/chat/types.ts
export type MessageStatus = "queued" | "sending" | "sent" | "delivered" | "read" | "error"
export interface Message {
id: string
chatId: string
authorId: string
contentType: "text" | "image" | "audio"
text?: string
media?: MediaAttachment
status: MessageStatus
createdAt: string
updatedAt?: string
// ... other fields
}
Status progression
Messages automatically progress through statuses in order:
queued
The initial status when a message is created. The message is added to state and visible in the UI.const message: Message = {
id: generateMessageId(),
chatId,
authorId: profile.id,
contentType: "text",
text: "Hello!",
status: "queued", // Starting status
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
sending
Indicates the message is actively being transmitted. In a real app, this would represent the network request in progress.
sent
The message has been successfully delivered to the server but not yet received by the recipient’s device.
delivered
The message has been received by the recipient’s device. WhatsApp shows two gray checkmarks at this stage.
read
The recipient has opened the chat and viewed the message. WhatsApp shows two blue checkmarks.
error (special case)
If message sending fails, the status becomes error. This status does not participate in automatic progression.
The progression is unidirectional - messages cannot move backwards through statuses (except to error).
Automatic lifecycle scheduling
When a message is sent, the scheduleLifecycle function sets up timers for automatic status progression:
// lib/chat/state.ts
const statusTimers = new Map<string, ReturnType<typeof setTimeout>>()
function scheduleLifecycle(
messageId: string,
get: () => ChatStore,
set: (updater: (state: ChatStore) => ChatStore | Partial<ChatStore>) => void,
initialDelay = 350
) {
const steps: MessageStatus[] = ["queued", "sending", "sent", "delivered", "read"]
steps.forEach((status, idx) => {
const delay = initialDelay + idx * 450 // Stagger each status by 450ms
const timer = setTimeout(() => {
set((state) => {
const existing = state.messages[messageId]
if (!existing || existing.status === "error") {
return state // Don't update if message removed or errored
}
const alreadyAtStatus = existing.status === status
const statusOrder = steps.indexOf(existing.status)
if (alreadyAtStatus || statusOrder > idx) {
return state // Don't move backwards
}
const nextMessage: Message = {
...existing,
status,
updatedAt: new Date().toISOString(),
}
const chat = state.chats[nextMessage.chatId]
if (!chat) return state
return {
...state,
messages: { ...state.messages, [messageId]: nextMessage },
chats: {
...state.chats,
[chat.id]: {
...chat,
lastMessagePreview: derivePreview(nextMessage),
lastActivityAt: nextMessage.createdAt,
},
},
}
})
}, delay)
statusTimers.set(`${messageId}-${status}`, timer)
})
}
Timing configuration
Default timing
Customizing delays
const initialDelay = 350 // First status change after 350ms
const stepDelay = 450 // Each subsequent status after 450ms
// Timeline:
// t=0ms -> queued
// t=350ms -> sending
// t=800ms -> sent
// t=1250ms -> delivered
// t=1700ms -> read
The initialDelay parameter can be adjusted when calling scheduleLifecycle:// Faster progression
scheduleLifecycle(messageId, get, set, 100)
// Slower progression (simulating slow network)
scheduleLifecycle(messageId, get, set, 1000)
Integration with message sending
The lifecycle is triggered when sendComposerPayload is called:
sendComposerPayload: (chatId, payload) => {
const chat = get().chats[chatId]
if (!chat) return undefined
const id = generateMessageId()
const timestamp = new Date().toISOString()
const message: Message = {
id,
chatId,
authorId: get().profile.id,
contentType: payload.contentType ?? "text",
text: payload.text,
media: payload.media,
status: "queued", // Starts at queued
createdAt: timestamp,
updatedAt: timestamp,
}
// Add message to state immediately
set((state) => ({
...state,
messages: { ...state.messages, [id]: message },
chats: {
...state.chats,
[chatId]: {
...chat,
messageIds: [...chat.messageIds, id],
lastActivityAt: timestamp,
lastMessagePreview: derivePreview(message),
},
},
}))
// Start automatic status progression
scheduleLifecycle(id, get, set)
return id
}
The message appears instantly in the UI with queued status, providing immediate feedback. Status updates then happen automatically in the background.
Timer cleanup
To prevent memory leaks and unexpected behavior, timers are cleaned up when:
- The store is reset
- A message enters error state
- A message is deleted
resetStore: () => {
statusTimers.forEach((timer) => clearTimeout(timer))
statusTimers.clear()
set({ ...initialState })
}
Visual representation in UI
Message status is typically shown with checkmark indicators:
// components/chat/message-bubble.tsx (conceptual)
function MessageStatusIcon({ status }: { status: MessageStatus }) {
switch (status) {
case "queued":
case "sending":
return <ClockIcon /> // Single clock icon
case "sent":
return <CheckIcon /> // Single gray checkmark
case "delivered":
return <DoubleCheckIcon /> // Two gray checkmarks
case "read":
return <DoubleCheckIcon className="text-blue-500" /> // Two blue checkmarks
case "error":
return <AlertIcon className="text-red-500" /> // Red alert icon
}
}
This matches WhatsApp’s familiar checkmark system that users expect.
Handling incoming messages
Messages received from other users arrive with read status since they’ve been delivered:
// lib/chat/helpers.ts
export function createInboundTextMessage(options: {
chatId: string
authorId: string
text: string
createdAt?: Date
}): Message {
const createdAt = options.createdAt ?? new Date()
const iso = createdAt.toISOString()
return {
id: generateMessageId(),
chatId: options.chatId,
authorId: options.authorId,
contentType: "text",
text: options.text,
status: "read", // Incoming messages start at "read"
createdAt: iso,
updatedAt: iso,
}
}
Real-world integration
In a production application with a real backend:
Send message to API
sendComposerPayload: async (chatId, payload) => {
const message = createMessage(payload, "queued")
addToState(message)
try {
const response = await api.sendMessage(message)
updateStatus(message.id, "sent")
} catch (error) {
updateStatus(message.id, "error")
}
}
Listen for delivery confirmations
socket.on("message:delivered", ({ messageId }) => {
updateMessageStatus(messageId, "delivered")
})
socket.on("message:read", ({ messageId }) => {
updateMessageStatus(messageId, "read")
})
Update status based on events
updateMessageStatus: (messageId, status) => {
set((state) => ({
messages: {
...state.messages,
[messageId]: {
...state.messages[messageId],
status,
updatedAt: new Date().toISOString(),
},
},
}))
}
Status validation
The lifecycle system includes safeguards against invalid transitions:
const alreadyAtStatus = existing.status === status
const statusOrder = steps.indexOf(existing.status)
if (alreadyAtStatus || statusOrder > idx) {
return state // Prevent moving backwards or redundant updates
}
This ensures messages cannot:
- Move from
delivered back to sent
- Jump directly from
queued to read (must go through intermediate states)
- Update to the same status twice
The error status is special - once a message is in error state, no automatic progression occurs. User intervention (retry button) is required.
Testing message lifecycle
To test different scenarios:
// Simulate slow network
scheduleLifecycle(messageId, get, set, 2000)
// Simulate instant delivery
scheduleLifecycle(messageId, get, set, 0)
// Manually set error state
set((state) => ({
messages: {
...state.messages,
[messageId]: { ...state.messages[messageId], status: "error" }
}
}))