Use one WebSocket for real-time events, a normalised store, a reverse virtualized message list, and optimistic sending with client ids.
Requirements:
- a list of conversations
- message history
- sending text and attachments
- real-time delivery
- typing indicators
- read receipts
- unread counts
Non-functional: messages appear instantly, survive flaky networks, and long histories scroll smoothly.
Architecture:
- A sidebar
ConversationList. - A
MessageList: a reverse virtualized list anchored at the bottom. Virtualized means only visible messages are rendered. - A
Composerfor writing messages.
A single WebSocket connection is used, because traffic is two-way and frequent. It feeds a normalised store of conversations and messages, keyed by id.
Data and API: history loads over HTTP, with cursor pagination going backwards (before=<messageId>). The socket events are message.new, message.updated, typing and read. Each one has a sequence number per conversation.
Sending:
- Create the message locally with a client id and a
pendingstatus. - Render it immediately.
- Send it over the socket.
- When the server acknowledges it with the real id, reconcile the two.
Failed messages show a retry option. Unsent messages are saved in IndexedDB.
Optimisations:
- Keep the scroll position anchored when older messages load at the top.
- Auto-scroll only if the user is already at the bottom.
- Throttle typing events, so at most one is sent per short interval.
- Reconnect with backoff (longer waits between tries), and fetch missed messages by sequence number.
Trade-offs: a normalised store adds complexity, but it keeps data consistent across views.
- Use a WebSocket for two-way real-time traffic.
- Send optimistically with client ids, then reconcile when the server acknowledges.
- Use a reverse virtualized list with scroll anchoring.
- Use sequence numbers to fill gaps after a reconnect.
- Normalise conversations and messages by id.