Skip to main content
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:
1

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(),
}
2

sending

Indicates the message is actively being transmitted. In a real app, this would represent the network request in progress.
3

sent

The message has been successfully delivered to the server but not yet received by the recipient’s device.
4

delivered

The message has been received by the recipient’s device. WhatsApp shows two gray checkmarks at this stage.
5

read

The recipient has opened the chat and viewed the message. WhatsApp shows two blue checkmarks.
6

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

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

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:
  1. The store is reset
  2. A message enters error state
  3. 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:
1

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")
  }
}
2

Listen for delivery confirmations

socket.on("message:delivered", ({ messageId }) => {
  updateMessageStatus(messageId, "delivered")
})

socket.on("message:read", ({ messageId }) => {
  updateMessageStatus(messageId, "read")
})
3

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" }
  }
}))

Build docs developers (and LLMs) love