System Designhard11 min read

Design Slack (Team Messaging)

Multi-channel chat on the client: a normalized store keyed by channel, unread counts and the new-messages divider, fast channel switching, threads, presence, and search across messages.

Published · by Frontend Masters India

Slack is WhatsApp's chat core multiplied by a few hundred channels, threads, and a sidebar full of unread badges. The chat mechanics (real-time transport, optimistic send, ordering) are the same and you should reuse them. What this prompt actually tests is data organization: how do you hold many channels in memory, switch between them instantly, keep unread counts honest, and not refetch the world every time someone clicks a different channel. If you've done the WhatsApp prompt, lead with "the per-message stuff is the same, here's what's different."

1. Scope it first

  • How many channels and how big are they? A workspace can have hundreds of channels; you can't keep all messages for all of them hydrated.
  • Threads? Replies-in-thread change the data model and the read-state model.
  • Unread behavior? Per-channel counts, mentions vs regular messages, a "new messages" line when you open a channel.
  • Search scope? Within a channel, across the workspace, with filters (from:, in:, date)?

Assume: many channels, threads, per-channel unread counts with separate mention counts, a new-messages divider on open, and workspace-wide search.

2. A normalized store keyed by channel

The mistake is nesting messages inside channels inside the workspace and re-rendering the tree on every event. Normalize instead. Keep flat lookup tables and reference by id, the way you'd shape a Redux or Zustand store.

type Store = {
  channels: Record<string, Channel>;          // metadata, unread, lastRead
  messagesByChannel: Record<string, string[]>; // channel id → ordered message ids
  messages: Record<string, Message>;           // id → message
  threadsByParent: Record<string, string[]>;   // parent msg id → reply ids
  members: Record<string, User>;
  presence: Record<string, "active" | "away">;
};

Normalizing means an incoming message updates one entry in messages and pushes one id into one channel's list. A presence change updates one presence entry. Components subscribe to the slices they care about, so a message in #random doesn't re-render the thread you have open in #eng.

<Workspace>
  <Sidebar>              // channel list with unread badges
  <ChannelView>
    <MessageList />      // virtualized, the WhatsApp list
    <Composer />
  <ThreadPanel />        // optional, opens beside the channel
</Workspace>

3. Switching channels without refetching everything

Clicking a channel should feel instant. The trick is caching what you've already loaded and only fetching the gap.

Keep each channel's loaded messages in the store even after you navigate away. When the user returns, render from the cache immediately, then fetch only messages newer than your last loaded id to fill any gap. You re-render the screen with cached data on the same tick as the click, and the network request quietly tops it up.

function openChannel(id) {
  setActive(id);                       // instant, renders cached messages
  const since = lastMessageId(id);
  fetchMessagesSince(id, since);       // background top-up
  markChannelRead(id);                 // clears unread once viewed
}

You do need to bound memory. With hundreds of channels you can't keep every message forever, so evict the message lists of channels the user hasn't touched in a while, keeping their metadata and unread count. Re-entering an evicted channel does a fresh page fetch, which is fine because it's rare.

4. Unread counts and the new-messages divider

Unread is a function of two markers: the channel's latest message and the user's lastReadId. Everything after lastReadId is unread. Mentions are counted separately because they badge differently (a red number, not just a bold channel name).

const unread = messages.filter(m => m.seq > channel.lastReadSeq).length;
const mentions = messages.filter(
  m => m.seq > channel.lastReadSeq && m.mentions.includes(me)
).length;

When you open a channel, draw the new-messages divider at the first message after lastReadId, before you advance the marker. That line is what lets someone see exactly where they left off. Advance lastReadId to the bottom only once the user has actually viewed the latest message (use an IntersectionObserver on the last row), then sync it to the server so other devices agree.

Subtle bug to mention: if you mark read on open but the user immediately scrolls up, the divider should stay where it was for this session. Compute the divider position once on entry, not reactively.

5. Threads

A thread is a sub-conversation hanging off a parent message. Model replies in their own list (threadsByParent) so the main channel timeline isn't polluted with every reply. The parent shows a summary ("12 replies, last at 4:02"). Opening a thread loads its replies into the side panel and reuses the same message list and composer components.

Read state is per-thread too, so a thread you're following can be unread while the channel itself is read. This is why a flat boolean for unread doesn't work and you key everything by id and sequence.

6. Presence and typing

Presence comes over the socket and lives in its own slice so it can update at high frequency without churning message components. Typing indicators are scoped to the channel or thread and debounced the same way as WhatsApp: fire on first keystroke, clear after a few seconds idle or on send. With a busy workspace, batch presence updates rather than dispatching one re-render per event.

7. Search across messages

Search is mostly a backend job (a full-text index over messages the user can access), but the frontend earns its keep here. Debounce the query, cancel stale requests with AbortController so an old slow response can't overwrite newer results, and render results as a separate view rather than mutating the channel store. Support the operators people expect (from:@ana, in:#eng, before:2026-01-01) by parsing them client-side into structured query params. Highlight the matched terms in each result and let Enter jump to that message in its channel, loading the surrounding context page.

8. Performance

The sidebar can list hundreds of channels; virtualize it if it gets long. The message list is the WhatsApp virtualized, bottom-sticking list. Keep the store normalized so updates are O(1) and subscriptions are narrow. And debounce the firehose: a busy workspace emits a lot of presence and typing events, so batch socket events into a single store update per animation frame instead of dispatching each one.

What the interviewer will push on

  • "How do you switch channels instantly?" Cache loaded messages in a normalized store, render cached on click, fetch only the gap in the background, evict cold channels to bound memory.
  • "Where exactly does the new-messages line go?" At the first message with sequence greater than lastReadId, computed once on channel entry so scrolling doesn't move it.
  • "Unread count looks wrong on a second device." lastReadId is synced to the server; counts derive from it, so all devices converge once the marker propagates.
  • "A channel has 100k messages." Page from the server, virtualize rows, keep only a window hydrated, and load older pages upward with scroll-position preservation.
  • "How is this different from WhatsApp?" Same per-message machinery; the new work is the normalized multi-channel store, derived unread state, threads, and search.

The one-paragraph recap

Slack reuses the WhatsApp chat core and adds a normalized store keyed by channel so a message in one channel never re-renders another. Channel switching is instant because you keep loaded messages cached, render them on click, and fetch only the gap, evicting cold channels to stay within memory. Unread counts and the new-messages divider are derived from a per-channel lastReadId synced across devices, threads live in their own keyed lists with their own read state, and search debounces, cancels stale requests, and parses operators client-side. Lead with the normalized store and derived unread state; that's the part this prompt is really about.

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

More design prompts