Design: Chat (Optimistic UI + WebSocket + Presence)
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 Optimistic Updates and Rollback and Retries, Backoff, and Idempotency (from the Frontend).
WebSocket lifecycle
Open once at app boot (per WebSockets — Integration, Reconnection, and Real-Time UX):
- 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;
// stay anchored
container.scrollTop += newHeight - prevHeight;
});
}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).
Related
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/
Interview angle 6
- “What transports does a chat app need?” - two. HTTP for durable operations
- sending, history, edits - and a WebSocket for live delivery. Sending over the socket with a POST fallback is fine as long as both carry the same idempotency key, or a fallback after a timeout duplicates the message.
- “How does an optimistic send work?” - generate a client message id, insert the message locally as pending, and replace it with the server’s version on ack. That same id is the idempotency key, generated once for the logical send and reused across every retry.
- “Reconnection is not enough. What else?” - a resume mechanism. Track the last server message id per channel and ask the server to replay from there on reconnect, or every message sent during the gap is silently missing and the user never knows.
- “How do you order messages?” - by server timestamp, since client clocks disagree and are trivially wrong. The client id exists for deduplication, not for ordering.
- “When do you auto-scroll?” - only when the user is already at the bottom. If they have scrolled up to read, show a “new messages” pill instead - moving the viewport under someone mid-sentence is the same sin as the feed insert.
- “Where do typing and presence live?” - in a separate ephemeral store, never persisted and never in the message list. They are high-frequency, worthless after a few seconds, and mixing them into message state means re-rendering the transcript on every keystroke someone else makes.