Frontend System Design: Build Slack’s Message Thread UI

“Design Slack” is a familiar backend system-design prompt. The frontend version is different — Slack’s message thread UI is genuinely complex once you account for nested threads, virtualization, presence, drafts, and edit history. Senior frontend interviews probe whether you can decompose this into a state model that handles all of it without becoming spaghetti.

Clarify scope

  • Channel view, thread view, or both?
  • Read-only or composer included?
  • Real-time updates from other users?
  • Edit/delete/reactions in scope?
  • File attachments / inline previews?

State model

The most important architectural decision. A typical shape:

  • messagesById: Map<id, Message> — single source of truth
  • channels: Map<channelId, { messageIds: string[], cursor }>
  • threads: Map<parentId, { replyIds: string[], cursor }>
  • presence: Map<userId, status>
  • drafts: Map<channelOrThreadId, draftText>
  • readState: Map<channelId, lastReadMessageId>

Critical rule: never duplicate the message body. Channels and threads reference IDs; the body lives in messagesById. An edit in one place propagates everywhere.

Layout

  • Sidebar (channels, DMs, threads I am following)
  • Main pane (current channel, virtualized message list, composer)
  • Right pane (thread view when a message is opened in thread)

The thread is a separate panel, not a modal — Slack chose this so users can read the channel and the thread simultaneously.

Message virtualization

Channels can have hundreds of thousands of messages. Render only the visible window:

  • react-virtual / TanStack Virtual handles the windowing
  • Variable-height rows (messages can be one line or include images)
  • Bidirectional scrolling: load older messages on scroll up, newer on scroll down
  • Anchor scroll position when prepending older messages so the user does not jump

Real-time updates

WebSocket pushes new messages, edits, deletes, reactions, presence:

  • Append new messages at the bottom; if user is at the bottom, scroll smoothly; if scrolled up, show a “New messages” pill
  • Edits update messagesById by ID; views that reference that message re-render
  • Deletes are tombstones — keep the message slot but replace body with “[deleted]”

Threads and the “thread broadcast” wrinkle

A reply in a thread can also be broadcast to the channel. Data shape: a thread reply with a flag like also_send_to_channel. The same message appears in two places (channel and thread). Your state model handles this naturally if messages are referenced by ID.

Drafts

Per-channel and per-thread drafts persist across navigation:

  • Saved to localStorage on every change (debounced)
  • Restored on mount
  • Cleared on send
  • Survive accidental tab close

Presence

  • Channel sidebar shows green dot for active users
  • Updated via the WebSocket presence stream
  • Per-DM, the “active 5 min ago” status is shown
  • Presence is ephemeral — never persisted on the client beyond memory

Read state

  • Server tracks last-read message per channel per user
  • Client locally optimistically updates last-read when scrolled past a message
  • Unread count derived from server-known last-read vs latest message

Reactions

Map<messageId, Map<emoji, Set<userId>>>. On reaction add/remove, optimistic update the local map; reconcile on server confirmation.

Edit history

  • Edits show “(edited)” in the message footer
  • Hover/click to view edit history (optional)
  • Server stores history; client fetches on demand

Performance gotchas

  • Avoid re-rendering the whole channel on every WebSocket event — memoize message components by ID
  • Use a stable reference for the messages array; avoid recreating it
  • Defer image loads with Intersection Observer
  • Throttle scroll-position updates

What separates senior from staff

Senior candidates draw the state model with messagesById as the single source of truth and discuss virtualization. Staff candidates discuss conflict resolution for edits during reconnect, the offline draft persistence strategy, and the access-control story (who can see this thread).

Frequently Asked Questions

How do I handle 10,000 channels in the sidebar?

Virtualize. Sort by recent activity. Group into sections (channels, DMs, apps). Lazy-load presence for off-screen channels.

What about offline?

Persist messagesById and channel cursors to IndexedDB. On reconnect, fetch deltas. Drafts already work offline.

How does the new-message indicator work?

Track scroll position. If user is within X pixels of bottom, auto-scroll on new message. Otherwise show a “Jump to newest” pill with a count badge.

Scroll to Top