Skip to content
Frontend system design ยท about 45 minutes

Web chat client

Design the web client of a chat product. Users have one-to-one and group conversations. The screen shows a list of conversations and the open conversation with its messages. Messages are text for now. A user sees new messages at once, sees whether their own message was sent, and can scroll up to load older messages. The same user can be signed in on several devices and tabs. The connection can drop, for example on a train.

Ask first

  • How large can a group be: 10 people or 10,000? This changes read receipts and presence.
  • Which states do we show for a message: sending, sent, delivered, read? Do we show typing and online status?
  • Must the user be able to read and write while offline, with messages sent later?
  • Text only, or also images, files, reactions, edits and deletes?
  • Is end-to-end encryption required? It changes search and history on a new device.
  • How much history loads at first, and is there search in messages?

The outline

Write your own outline for each part first, then open it to compare with the points a strong answer covers.

Requirements
  • Functional: list conversations, open one, send and receive text, load older messages, show unread counts
  • A message I send appears at once with its state: sending, sent or failed
  • Everyone in a conversation sees the messages in the same order, with no duplicates
  • The client recovers from a lost connection without a reload and without losing messages
  • All tabs and devices of one user show the same messages and unread counts
  • Long conversations open fast and scroll smoothly
Architecture
  • A WebSocket carries live events in both directions; HTTP loads the conversation list and message history
  • A connection manager owns the socket: connect, heartbeat, reconnect with backoff and a random delay, and subscribe again
  • A message store is the one source for the views; socket events and HTTP responses both write to it
  • An outbox holds my messages that the server has not confirmed, and sends them again after a reconnect
  • After a reconnect, the client asks for the messages after the last one it has, to fill the gap
  • The server sends each event to every open connection of the user, so the other tabs and devices update too
  • Views: conversation list, message list (virtualized, anchored to the bottom) and composer
Data model
  • Message: id (from the server), clientId (made by the client), conversationId, senderId, text, createdAt, sequence, status
  • Conversation: id, type (direct or group), memberIds, title, lastMessage, unreadCount, lastReadSequence
  • Store: conversationsById, messagesById, and for each conversation an ordered list of message ids and a cursor for older history
  • The order inside a conversation comes from the server's sequence number, not from the clocks of the clients
  • Outbox item: clientId, conversationId, text, attempts; removed when the server acknowledges that clientId
  • Draft text per conversation, kept locally so it survives a switch to another conversation
Interface
  • GET /conversations?cursor= returns conversations with the last message and the unread count
  • GET /conversations/:id/messages?before=<sequence>&limit=30 returns one page of older messages
  • Send over the socket: { type: 'message.send', clientId, conversationId, text }; the server answers { type: 'message.ack', clientId, id, sequence, createdAt }
  • Receive: { type: 'message.new', message }, { type: 'message.read', conversationId, userId, sequence }, { type: 'typing', conversationId, userId }
  • The clientId works as an idempotency key: the server stores one message for it, even if the client sends it twice
  • MessageList props: messages, onLoadOlder, onRetry(clientId); Composer props: onSend(text), disabled
Optimizations
  • Optimistic send: the message shows with status sending, is matched to the ack by clientId, and becomes failed with a retry button after a timeout
  • Scroll: follow new messages only when the user is already at the bottom; otherwise show a 'New messages' button
  • Loading older messages keeps the visible message in place: the scroll offset is corrected after the items are added above
  • Long history is virtualized; rows have different heights, so they are measured
  • Typing events are throttled and read receipts are batched, so the socket is not flooded
  • Accessibility: the message list has role log, so new messages are announced; Enter sends and Shift+Enter adds a line
  • Safety: message text is rendered as text, and links become anchors with checked URLs, never raw HTML

Pitfalls

  • Ordering messages by client clocks, which differ between devices
  • No idempotency key, so a retry after a timeout creates a second message
  • Jumping to the bottom on every new message while the user reads older ones
  • Reconnecting in a fast loop with no backoff when the server is down
  • Trusting the socket to deliver everything, and not loading missed messages after a reconnect

Follow-ups an interviewer asks

  • The user sends a message while offline in a tunnel. Describe each step until it is delivered.
  • How do you make sure that a message is never shown twice?
  • Why a WebSocket here, and not Server-Sent Events or polling?
  • The user reads a conversation in one tab. How does the unread count change in the other tab?
  • How would read receipts work in a group of 5,000 people?
  • What changes in your design if messages must be readable offline?