AniUI Academy

Worked Scenario: Design a Chat With Threads (Teams / Slack)

A full worked answer to the Microsoft-style chat prompt — virtualized bidirectional scroll with pinned bottom, threads without route changes, presence that doesn't lie, and typing indicators without a thundering herd.

15 min read

A channel-based chat with threads — Teams, Slack, Discord — is one of Microsoft's most-asked frontend prompts because every piece of it is a visible stress test of this course's content: virtualization under live updates, concurrent user actions, URL state, presence done honestly.

Clarifying requirements first

Before proposing anything, the questions worth asking out loud: Rough largest channel — thousands of messages, millions? Threads first-class or an afterthought? Presence (online/away) required? Typing indicators required? Message editing and deletion in-scope? File attachments, or text only? Mobile first-class, or desktop with mobile reader? For this answer, assume: a busy channel may hold hundreds of thousands of historical messages; threads are a core workflow; presence and typing indicators are both required; edit and delete are in-scope; attachments appear in the message list but their upload pipeline is out of scope; desktop-first with a mobile-first-class view.

Rendering strategy: SSR shell, CSR everything-live

The chat is behind a login — SSR for the shell (sidebar, header, active channel bar) with cookies attached gives a visually-complete first paint. Message rendering, scroll, live updates, and composition are CSR. The initial channel's most-recent page of messages can land in the SSR payload to avoid a request waterfall on first paint.

The message list: variable-height virtualization with pinned bottom

A channel is an unbounded, append-mostly list where every message differs in height (text length, attached images, reactions row, a thread count chip). That's the variable-height virtualization case: items are rendered and measured, with an estimated height while off-screen, same as the social-media-feed scenario.

Two refinements the feed scenario doesn't need:

  • Pinned-to-bottom while at bottom: the live tail only auto-scrolls to the newest message while the user is already at the bottom. The moment they scroll up to read history, the pin releases; the moment they scroll back to the bottom, the pin re-engages. A "N new messages" affordance appears while scrolled up with new arrivals.
  • Two-direction windowing: scroll up fetches older messages in chunks, rendered above the current view with the scroll position preserved across the mount (anchor-adjust). The in-memory message cap is a sliding window around the current view — older-than-cap messages drop out of state and re-fetch on scroll-back-up.

The data model: normalized, keyed by channel + id

messages.byId

Each message stored once by id. Author id references, not embedded user objects.

users.byId / channels.byId

Shared references so a display-name or avatar change updates every message that references them.

views: channel.recentIds / thread.ids

Ordered id lists per rendered view. The thread is a parallel view onto the same normalized store, not a copy of the messages.

The thread-as-parallel-view detail matters: a message that opens a thread appears both in the main channel scroll and in the thread pane. One edit updates both. One normalized store, two views.

Threads as a parallel URL, not a route change

Clicking a thread opens a sidebar pane next to the main channel. The user expects the main channel to keep scrolling live, the thread to appear alongside, and the URL to be shareable (so sending /channel/42/thread/789 in another chat lands the recipient in exactly that view).

The honest implementation is a parallel URL segment (Next.js parallel routes, or an app-level router that supports multi-slot layout), with the thread pane mounted as a sibling of the main channel. Both read from the same messages.byId store, so a new message in either pane doesn't re-fetch or re-render the other. A modal would not be deep-linkable; routing away loses main-channel state and feels wrong.

Typing indicators without a thundering herd

A naïve "send a WebSocket message per keystroke" scales to a channel of ten users and collapses on a channel of two thousand. The honest model is three layers:

  1. Step 1

  2. Step 2

  3. Step 3

Mobile battery is the forgotten cost here — sending ten events per second upstream for every typing user eats battery for a feature that's visibly unchanged whether sent at 10Hz or 0.5Hz.

Presence: heartbeat with a timeout, not a connection count

Same reasoning as the collaborative wiki scenario: counting WebSocket connections for "who's online" lies the moment a user has the app open in two tabs, two devices, or just reloaded. The honest primitive is a periodic heartbeat per user ("I am active"), broadcast to subscribers, with a server-side timeout clearing stale ones. A tab close fires navigator.sendBeacon as a hint; the timeout is the authority.

Message editing and deletion: optimistic with a rollback path

Edit flips the message content in-place optimistically and fires the mutation; a failed edit reverts the message to its previous version with a non-disruptive error. Delete fades the message out optimistically and confirms the server-side delete; failure undoes the fade and surfaces the reason. The pattern is the social-feed like-button pattern applied here — the point is to feel instant without lying when the server refuses.

WebSocket reconnection: re-hydrate, don't replay

Same discipline as the Kanban and CI scenarios: on reconnect, re-fetch the current channel head rather than trusting a server to replay missed events in order. The event stream is a live tail; it is not the source of truth. A tab that was asleep for an hour cannot assume the server will send it the hour's events in sequence.

What's explicitly out of scope, and why

Not solved in this answer: the attachment upload pipeline (its own infrastructure problem, especially around chunked resumable uploads and antivirus scanning); search across all channels (a backend indexing problem, with its own perms model); the voice/video call surface (a different medium with its own architecture); client-side encryption (its own trust and key-management problem). Scoping these out is a strength.

What to remember

  • Variable-height virtualization is non-optional for a long channel. The two refinements over a feed: pinned-to-bottom while at bottom, and two-direction windowing with scroll-position preservation on backward fetch.
  • Threads are a parallel URL segment with a sibling pane, both panes reading from the same normalized store. Modals, inline expansions, and route-changes all break the primary workflow.
  • Typing indicators coalesce client-side (one started, one stopped — not per keystroke), server dedupes and broadcasts, receivers hold the display a few seconds beyond the last signal.
  • Presence is a heartbeat with a server-side timeout; connection counts lie the moment a user has two tabs open.
  • Edit and delete follow the optimistic-with-rollback pattern — flip the UI instantly, revert cleanly on server refusal.
  • Reconnect is a re-hydrate, not a replay. The event stream is a live tail; the server is the source of truth and the state has to be re-fetched.

Check yourself

3 questions · pass 3/3 to unlock Worked Scenario: Design a Search Autocomplete (Google-shape)

up to 50
  1. 1.A busy channel has 400 000 historical messages and new ones arriving every few seconds. The viewer needs to keep up with the live tail and also let the user scroll back to day one without the browser dying. What's the right rendering primitive?

  2. 2.Threads need to feel like a sidebar off the main channel — a reply opens the thread in a right-hand pane without navigating away or re-fetching the main channel. What's the state and routing story that honestly delivers that?

  3. 3.Typing indicators ('X is typing…') naively broadcast one WebSocket message per keystroke per user, which produces a thundering herd on a channel of 2 000 members. What's the right throttling model?

3 left to answer

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.