Design: Chat (Optimistic UI + WebSocket + Presence)
TL;DR
A messaging UI with instant local echo, real-time inbound messages, presence (“Alice is typing”/“online”), read receipts, and graceful behavior across disconnects. The senior topics: optimistic send with idempotency, WebSocket lifecycle + reconnection + resync, message ordering under reorder/duplication, scroll/anchor behavior on inbound messages, and separating live state (presence, typing) from durable state (messages).
Requirements to clarify
- DM-only or channels/rooms? Scale per channel?
- Message types. Text only, or rich (files, links, replies, reactions, threads)?
- Read receipts. Per-user delivered/read, or just unread count?
- Edit/delete. Within N minutes?
- History limit. Last 10K messages or infinite scroll back to channel inception?
- Notifications. Browser push when tab closed?
- End-to-end encryption? (Massively changes scope — out of scope here.)
API contract
Two transports:
- HTTP for durable resources: load history, send (also via WS but HTTP is a fallback), edit, delete, list channels.
- WebSocket for live: new messages inbound, typing indicators, presence.
GET /api/channels/:id/messages?before=<cursor>&limit=50 → history (older direction)
POST /api/channels/:id/messages { "clientMsgId": "uuid", "body": "...", "replyTo": "..." }
PATCH /api/messages/:id { "body": "edited" }
DELETE /api/messages/:id
WS wss://...
→ { "type": "msg", "channelId": "...", "msg": {...} }
→ { "type": "typing", "channelId": "...", "userId": "...", "until": <ts> }
→ { "type": "presence", "userId": "...", "online": true }
← { "type": "send", "clientMsgId": "...", "channelId": "...", "body": "..." }
← { "type": "typing", "channelId": "..." }
← { "type": "ack", "clientMsgId": "...", "serverMsgId": "...", "createdAt": "..." }
Send via WS for latency; the server persists and broadcasts. Fall back to POST if WS is down (idempotency key = clientMsgId).
Client data model
- Messages per channel stored in TanStack Query (or normalized store) keyed by
["messages", channelId]. Cursor-paginated; new messages prepended (or appended depending on sort direction). pendingMessagesmap (clientMsgId → message) — local-only, unconfirmed sends. UI renders them dimmed/with a clock icon.typingUsersper channel — ephemeral, in-memory; clears on a timer.presenceper user — ephemeral, in-memory; updated by WS push.unreadCountsper channel — durable (server) + local optimistic.
Pattern: server state in TanStack Query, ephemeral live state in a small useReducer or store, kept separate.
Optimistic send
async function sendMessage(channelId: string, body: string) {
const clientMsgId = crypto.randomUUID();
// 1. Optimistic insert
queryClient.setQueryData<Message[]>(["messages", channelId], (old = []) => [
...old,
{ clientMsgId, channelId, body, author: me, status: "pending", createdAt: new Date().toISOString() },
]);
// 2. Send via WS (or POST fallback)
try {
socket.send({ type: "send", channelId, clientMsgId, body });
} catch {
await fetch(`/api/channels/${channelId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": clientMsgId },
body: JSON.stringify({ clientMsgId, body }),
});
}
}
// 3. On ack, replace temp with server message
socket.on("ack", ({ clientMsgId, serverMsgId, createdAt }) => {
queryClient.setQueryData<Message[]>(["messages", channelId], (old = []) =>
old.map(m => m.clientMsgId === clientMsgId ? { ...m, id: serverMsgId, status: "sent", createdAt } : m)
);
});
clientMsgId is both the dedup key (the server uses it as idempotency key) and the reconciliation key (the client uses it to swap the temp with the real). Same UUID for both — generated once per logical send, reused across retries.
See ../11_apis_data_fetching/03_optimistic_updates.md and ../11_apis_data_fetching/06_retries_backoff_idempotency.md.
WebSocket lifecycle
Open once at app boot (per ../11_apis_data_fetching/07_websockets.md):
- Reconnect with exponential backoff + jitter.
- Heartbeat every 30s, kill after 10s no pong (zombie detection).
- Resume after reconnect: send
{ type: "resume", channels: [...], since: { [channelId]: lastServerMsgId } }. Server replays missed messages per channel. - Auth via short-lived token in
Sec-WebSocket-Protocol(browsers won’t sendAuthorization).
The resume mechanism is what makes chat reliable. Without it, every reconnect silently drops messages and the user sees gaps.
Message ordering
Network can deliver out of order; server’s createdAt is the canonical sort key, not WS arrival order. The client sorts on insert.
But: clocks drift, so two messages from different servers can collide on createdAt. Tiebreak with serverMsgId (server-issued, monotonic enough per server). Some systems use a vector clock or Lamport timestamp; chat usually doesn’t need it.
Scroll behavior on inbound messages
The hard UX problem.
- User is at the bottom → auto-scroll to keep the new message visible.
- User has scrolled up to read history → don’t auto-scroll; show “↓ new message” pill.
- User is loading more history (scrolling up) → keep their scroll position anchored to the old bottom message (scroll position must be measured against a stable element, not the scroll offset, because new content has been prepended).
// Maintain scroll relative to a known anchor when prepending
function prependHistory(newMessages: Message[]) {
const container = scrollContainerRef.current!;
const prevHeight = container.scrollHeight;
setMessages(prev => [...newMessages, ...prev]);
// After render:
requestAnimationFrame(() => {
const newHeight = container.scrollHeight;
container.scrollTop += newHeight - prevHeight; // stay anchored
});
}
Typing indicator
User types → debounce 1s → emit typing event. The receiver shows “Alice is typing…” with a 3-5 second timeout (re-armed by subsequent events). Don’t emit on every keystroke.
const emitTyping = useMemo(
() => debounce(() => socket.send({ type: "typing", channelId }), 1000, { leading: true, trailing: false }),
[channelId]
);
Typing state is ephemeral — never persisted, lost on reconnect, that’s fine.
Presence
Two flavors:
- Online/offline — flipped by WS connection/disconnection. Server propagates to other users in the same channels.
- Active vs idle — based on browser focus/blur, mouse-move heartbeats, etc. Surface as “online 5m ago” rather than a binary.
Scale: presence updates can fan out enormously (10K-user channel = 10K updates per join). Backpressure on the server, batched broadcasts, or “online ≥X mins ago” tiers.
Failure modes
- WS drop mid-typing — local input untouched; on reconnect, the in-progress send is retried via the idempotency key.
- Duplicate ack from a server retry —
clientMsgIdlookup dedups (don’t insert if already there). - Message rejected (server returns error after WS send) — mark as failed in the pending list; show retry button.
- History fetch fails — keep showing what we have; show “load more” with a retry button on click.
- Browser tab backgrounded — WS may suspend; show “reconnecting…” on return; resume mechanism plays catch-up.
- Two devices for same user — both connections are separate; presence is OR (online if any device is online); messages echo to both.
Notifications
- In-app: unread badge per channel; sound on inbound (respect OS audio preference).
- Browser push: registered via Push API + service worker; respects user’s notification permission.
- Mobile push: out of scope here (native).
Telemetry
- WS connection uptime, reconnect frequency.
- Message send-to-ack latency p50/p95/p99.
- Inbound message arrival skew (clock difference vs server).
- Typing-event volume.
- Reconnect-resume backlog size (huge backlogs = server problem).
What a senior is expected to say
- “Separate transports: HTTP for durable, WS for live. WS send + POST fallback, both sharing the same idempotency key.”
- “Optimistic insert with a clientMsgId; on ack, replace the temp with the server message. Same UUID is the idempotency key — generated once, reused on every retry.”
- “Reconnect is mandatory and not enough — I need a
resumemechanism with a per-channellastServerMsgIdso the server replays missed messages.” - “Order by server timestamp; scroll behavior depends on whether the user is at the bottom — auto-scroll only when they are, otherwise show a ‘new message’ pill.”
- “Typing and presence are ephemeral and never persisted; they live in a separate store from messages.”
Cross-references
- WebSocket reconnection + resume: ../11_apis_data_fetching/07_websockets.md
- Optimistic updates pattern: ../11_apis_data_fetching/03_optimistic_updates.md
- Idempotency keys: ../11_apis_data_fetching/06_retries_backoff_idempotency.md
- Backend chat & presence: ../../system_design/07_worked_designs/05_chat_presence.md
Further reading
- Discord Engineering blog (large-scale chat patterns): https://discord.com/blog/category/engineering
- Slack on WebSockets reliability (talks/posts)
- W3C Push API: https://www.w3.org/TR/push-api/