cf.completefrontend
My progress
SYSTEM DESIGN · #107

Design Slack

The web and desktop client of a team chat app like Slack: many workspaces, hundreds of channels, threads, reactions, files, and search, open all day. The hard parts are a client store that starts instantly and stays correct through reconnects, unread state that matches what the user really saw, a message list that scrolls through years of history, and channels with thousands of members.

The question

Design the client for a team chat product like Slack. People belong to one or more workspaces. Each workspace has channels (public and private), direct messages, and group DMs. Messages can have threads of replies, emoji reactions, and files. People search years of history, get notified when someone mentions them, and keep the app open from morning to evening.

Some channels have tens of thousands of members and hundreds of messages an hour. Walk me through the client: what it loads at start, how it stores data, how it stays in sync over a live connection that drops, how it tracks what you have read, and how the message list, the composer, search, and notifications work.

Real-life examples: Slack, Microsoft Teams, Discord, Mattermost, Google Chat

Requirements: the questions that shape a team chat client

"Design Slack" sounds like "design a chat app" with more features. It is a different problem. A one-to-one chat app (see the Chat app guide) is about delivering a message from A to B, in order, with receipts.

Slack is about many conversations at once: a person sits in 150 channels across three workspaces, most of them busy, and the client must tell them, at a glance and correctly, where something new is waiting for them. The core of the design moves from "send and receive" to "keep a large local picture of many channels correct and cheap".

So the first five minutes are about size and shape: how many workspaces, channels, and members, how busy the channels are, how long the app stays open, and whether it runs in a browser tab, a desktop app, or both. Each answer moves a part of the design. The table shows what this guide assumes and what changes if the interviewer says otherwise.

QuestionAnswer we assumeWhat it changes
How many workspaces per person?Usually 1 to 3, sometimes 10 or more for consultants.The client holds several workspaces side by side, each with its own connection and data. Switching must be instant.
How many channels per person?50 to 300 joined channels and DMs per workspace. About 20 to 40 get visited on a normal day.The sidebar shows all of them with unread state, but message history loads only for the few that are opened.
How big is a channel?Most have 5 to 200 members. A few company-wide channels have 10,000 to 100,000.Member lists and presence can never be loaded whole. Everything about members is lazy and paged.
How busy?A busy channel gets 1 to 5 messages a second at peak. A whole workspace can send a client 20 to 50 events a second.Event handling must be cheap and batched. Typing and presence events can be dropped; messages cannot.
How long is history?Years. Millions of messages per workspace.History is paged from the server. Search runs on the server. The client keeps only a window of recent messages.
How long does the app stay open?All working day, often for days without a reload.Memory must stay flat. The connection will drop and come back many times. The app must survive deploys.
Web, desktop, or both?A web app, also shipped inside a desktop shell (Electron) for Mac, Windows, and Linux.One codebase. The desktop shell adds native notifications, a dock badge, and a long-lived process, but no new data model.
Offline?Read cached channels and write messages that send later. No offline search.An IndexedDB cache and a pending outbox. Search shows an "offline" state.

Functional requirements

  • Show a workspace switcher, and for the current workspace a sidebar of channels, DMs, and user-made sections, each with unread and mention state.
  • Show a channel's messages newest at the bottom, load older ones as the user scrolls up, and jump to any message from a link or a search result.
  • Send, edit, and delete messages with rich text, mentions (@name, @here, @channel), emoji, slash commands, and file uploads with progress.
  • Open a thread on any message in a side panel, reply there, and optionally also post the reply to the channel.
  • Add and remove emoji reactions on any message.
  • Show new messages, edits, reactions, typing, and presence in real time.
  • Track what the user has read per channel and per thread, show unread counts and mention badges, and sync that state across the user's devices.
  • Keep a draft per channel and per thread that survives switching channels, reloads, and devices.
  • Search messages and files with filters such as in:#design, from:@maya, has:link, before:2026-01-01.
  • Notify the user of mentions, DMs, and keywords on the desktop, respecting per-channel settings and do-not-disturb.
  • Full keyboard use and full screen reader use.

Non-functional requirements

  • Fast start: with a warm cache, the sidebar and the last open channel show real content in under 1 second. Cold start (empty cache) shows them in under 3 seconds on a mid-range laptop.
  • Instant switching: switching to a recently visited channel paints in under 100 ms from memory. Switching to a channel not seen today paints a skeleton at once and real messages within about 500 ms.
  • Live: a message from someone else appears within about 500 ms of being sent on a normal connection. The user's own message appears at once (optimistic) and is confirmed within a second.
  • Correct after any drop: after a network drop of any length, the client ends up with exactly the server's messages, with no gaps and no duplicates.
  • Honest unread state: under this design's default rule, a channel is marked read only when the user has really seen its newest messages or marks it read on purpose, and that state matches on every device within a few seconds.
  • Flat memory: after a working week open, memory stays in the same range as after the first hour. As a target for the web client, a few hundred MB, not gigabytes.
  • Smooth: scrolling a busy channel at 60 frames a second, typing with no visible delay, Interaction to Next Paint (INP, how fast the page reacts to a click or key) under 200 ms.
  • Accessible: WCAG 2.2 AA. Every action by keyboard, new messages announced without flooding the screen reader.

Out of scope, and why

  • Calls and huddles: audio and video are their own design (see the Video conferencing guide). We only show that a huddle is live in a channel.
  • The server side of search and fan-out: we design the client's side of the contract. How the server indexes messages or routes an event to 10,000 sockets is a backend question.
  • Apps, bots, and workflow builders: we treat a bot message as a normal message with blocks of layout. Building the app platform is out.
  • Admin settings, billing, and compliance exports: plain forms, no new client design.
  • End-to-end encryption: Slack does not do it, and it would change search and sync entirely. If the interviewer asks for it, say what breaks (server search) and move on.

Check your understanding · Exercise 1

Which of these answers from the interviewer would change the client design the most? Select all that apply.

What makes Slack hard

Before drawing boxes, name the forces that push on every decision. Each one below comes back in a later chapter, and in the interview it helps to say them out loud early: it shows you see the problem, not just the screens.

1. Many conversations at once

A Slack user has 150 channels and DMs in the sidebar, and any of them can change at any second. The sidebar is a live dashboard: bold for unread, a number for mentions, a faint look for muted.

The client needs a little state for every channel but full history for very few. Loading the last 50 messages of 150 channels at start would be 7,500 messages, roughly 4 to 8 MB of JSON by our estimate of 0.5 to 1 KB per message, just to draw a sidebar. So the store has two levels: a small summary for every channel and a message window for the few that are open.

2. The client is a replica that is always a little behind

The server is the source of truth; the client holds a copy of a slice of it, fed by a stream of events (posted, edited, deleted, reacted, joined, went away) at 20 to 50 a second in a busy workspace. Laptops sleep and networks switch, so the stream breaks many times a day, and each break leaves a hole the client must notice and fill exactly, without showing anything twice. This is a database replica's problem, and the same tools solve it: ids, ordering, and catch-up from a known point.

3. Read state is the product

People use Slack by scanning for bold channels and red badges. If the client marks a channel read when the user only clicked through it, they can miss a message that mattered; if it never marks things read, the badges become noise and people stop trusting them. And the state must match on the laptop, the phone, and the second browser tab. Read state is small data with large consequences, and it needs its own chapter.

4. Long history with random access

A channel can hold years of messages. Most of the time people read the bottom. But a search result, a link in another channel, or a "jump to unread" can drop them anywhere in that history, and from there they scroll both up and down. The list must load a window around any message, grow it in both directions, and keep the scroll position steady while content above loads.

5. A process that lives for days

A page that lives for 5 minutes can leak and nobody notices. A Slack window lives for a week, so every listener not removed and every message window not trimmed adds up. The server also ships new code several times a day while old clients keep running, so the client must accept events it does not understand and reload itself safely.

6. Channels with an audience

A company-wide channel with 50,000 members breaks every "just load all of it" idea. The member list, presence dots, "who reacted" lists, and mention autocomplete all have to work on pages and on what is visible. An @channel there notifies 50,000 people, so the client also has a part in protecting people from noise (warnings before posting, per-channel notification settings).

ForceWhat it rules outWhere we handle it
Many conversations at onceLoading history for every channel at startData model (two levels), Boot and loading
Always a little behindTrusting the socket stream to be completeReal-time events, Reconnect and outbox
Read state is the productChoosing a mark-read rule without naming its costUnread state and mentions
Long history, random accessOne list that only grows upwardThe message list
Lives for daysUnbounded caches and listenersPerformance and memory
Channels with an audienceFull member lists and presence for allLarge channels and presence

Think first

The Chat app guide already covers sending, ordering, receipts, and reconnects. Which of its ideas carry over to Slack unchanged, and which ones break?

The workspace shell: sidebar, message pane, and thread panel

Start the design with the screen, because it tells you what data each part needs and how often it changes. A Slack window has five regions, and each one has a different job and a different rhythm of updates.

A wireframe of the Slack window. On the far left, a workspace rail with three workspaces, A, B, and C; A is selected. Next, the sidebar: a Starred section with launch, which has 2 mentions, and design; a Channels section with eng and random, which is muted; and a Direct messages section with Maya and Priya. In the middle, the launch channel: a header, the messages Maya: ship it? and Sam: tests green, a red New line, then Ana: deploy at 3 and Leo: on it, and the composer at the bottom. On the right, the thread panel for Ana's message with two replies and a reply box.app.slack.com/client/T024BE7LD/C0G9QF9GWABCStarred# launch 2# designChannels# eng# randomDirect messagesMayaPriya# launchMaya: ship it?Sam: tests greenNewAna: deploy at 3Leo: on itMessage #launchThreadAna: deployMaya: +1Sam: doneReply
Five regions with five rhythms: the rail and header rarely change, the sidebar changes one row at a time, the message list changes constantly, and the thread panel only matters while open. Each gets its own rendering tactic.
RegionWhat it showsData it needsHow often it changes
Workspace railOne icon per workspace, with a dot or number when something is waiting thereOne unread flag and one mention total per workspaceRarely; only when totals cross zero
SidebarSections (Starred, custom sections, Channels, Direct messages), one row per conversationA summary per conversation: name, kind, muted, unread, mention count, presence for DMsOften: every new message anywhere can touch one row
HeaderChannel name, topic, member count, huddle statusThe channel recordRarely
Message pane and composerThe message list for one channel and the box to write inA window of messages, the users who wrote them, the draftConstantly while the channel is busy
Side panelA thread, a profile, channel details, or search resultsThe thread's replies, or the profileOnly while open

The lesson from the table: the sidebar and the message pane are the two hot spots, but for different reasons. The sidebar has many rows that each change a little. The message pane has one big list that changes a lot. They need different rendering tactics: per-row subscriptions for the sidebar, a virtual list for the pane.

What lives in the URL

Anything worth sharing or coming back to belongs in the URL: the workspace, the channel, the open thread, and a message to jump to. A path like /client/T024BE7LD/C0G9QF9GW?thread=1729412345.000200 makes links, the back button, and reloads work. Slack's web client URLs also carry the workspace and channel ids in the path; the thread and msg parameters are this design's. The scroll position, the draft, and collapsed sections are per-device view state and live in memory and IndexedDB instead.

Decision How should one window host several workspaces?

A full copy of the app per workspace

Each workspace runs as its own page: a separate webview, iframe, or process.

The shell only draws the rail and swaps which copy is visible.

Pros

  • Total isolation: a bug or a leak in one workspace cannot touch another.
  • Simple code: every module assumes exactly one workspace.

Cons

  • Memory grows with every workspace: each copy loads the whole framework, the code, and its caches. With 5 workspaces that can be several times the memory of one.
  • Shared things (your account, settings, emoji you use) are loaded 5 times.

One app, one store per workspace

One copy of the code. Each workspace gets its own store instance holding its data, its connection state, and its socket.

The UI reads from the store of the workspace on screen; background stores keep running.

Pros

  • Code and framework loaded once. Background workspaces cost only their data.
  • Clean boundary: a workspace can be booted, paused, or dropped as a unit.

Cons

  • Every piece of code must know which workspace it serves. Hidden globals become bugs.
  • Cross-workspace views (a total badge, a unified search) need a small layer above the stores.

One app, one global store, workspace id on every record

All entities from all workspaces live in one store, keyed by workspace id plus entity id.

Pros

  • Cross-workspace queries are easy.

Cons

  • Easy to leak data across workspaces by forgetting a filter, which is a security problem for enterprise customers.
  • Dropping one workspace means hunting its records everywhere.

What we pick: Pick one store per workspace inside one app. It is the memory win of a single app with the clean boundary of separate copies. Slack described making exactly this move in its 2019 desktop rebuild: the old client ran each workspace as a separate copy of the web app in its own Electron process, and the new one gives each workspace its own Redux store, including its WebSocket, inside one process. A thin "account" layer above the stores holds what is truly shared: the signed-in user, preferences, and the totals the rail shows.

Switching channels and workspaces fast

Switching is the most common action, so it gets a budget: under 100 ms for a recent channel. Three things make it possible.

First, the store keeps the message windows of the last 10 or so channels in memory. Second, the pane saves a scroll anchor per channel when you leave (the top visible message id and its pixel offset, not a raw scrollTop, because the list may change while you are away). Third, the next channel's history request starts on pointerdown or keyboard focus in the sidebar, before the click completes.

The sidebar: many small rows, each changing alone

A sidebar with 300 rows re-rendered on every event would cost a few milliseconds 30 times a second: a steady drain on the main thread. Instead, each row subscribes to its own conversation's summary through a selector, so a new message in #design re-renders one row. The sidebar's order (alphabetical, by recent activity, or custom per section) is computed once and changes only when a row moves. Up to a few hundred rows do not need virtualization; for people with thousands of channels, the long sections virtualize like any list.

Check your understanding · Exercise 2

A user leaves #eng scrolled halfway up, reads #design for ten minutes while 40 new messages arrive in #eng, then comes back. Which choices make the return correct? Select all that apply.

The data model: a normalized store with two levels

The store is the heart of a Slack client: everything else (the socket, the boot, the list) either fills it or reads it, so getting its shape right makes most later chapters simple. The naive shape is "a list of messages per channel, each message with its author object inside". It breaks in three ways at Slack's size.

  • Duplication: the same author appears in 4,000 messages. When she changes her display name or avatar, 4,000 copies are stale. Store users once and point to them by id.
  • The same message in several places: a message can be on screen in the channel, in the thread panel as the parent, and in search results. A reaction added in one place must show in all three. Store each message once and let views hold ids.
  • A list that only grows upward: jumping to a message from last year creates a block of history with a gap between it and today. A single array per channel cannot say "I have these two islands, and nothing in between".

So the store is normalized (each entity once, in a map by id, with references by id; see the Normalization lesson), and it has two levels: a small summary for every conversation the user is in, and full message data only for the few that have been opened.

The workspace store's data model. Top row, kept for every conversation: summaries, users by id loaded lazily, and drafts and the outbox. Bottom row, kept only for opened conversations: segments, which are lists of ts per channel or thread, pointing at messages keyed by channel and ts, which point at users by userId and at files by id. Summaries lead to segments only for opened conversations. Notes: level 1 is small and kept for all 300 conversations, level 2 is full and kept for about 10; a reply has thread_ts equal to its parent's ts, and reactions live on the message.Workspace storesummariesevery conversationusersby id, lazydrafts, outboxsurvive reloadssegmentsts lists, no gapsmessageskey: channel:tsfilesmetadata by idopened onlyuserIdlevel 1: small, all 300level 2: full, about 10reply: thread_ts = parentreactions: on the message
Every entity is stored once and referenced by id. A message's key is channel:ts, a segment is just a sorted list of those ts values, and a thread is a segment keyed by its parent.

Ids: channel plus timestamp

Slack's public API identifies a message by its conversation id plus a ts value: a string like "1729412345.000200", seconds since 1970 then a fractional part. The server assigns it, Slack's docs guarantee it is unique within the conversation, and it sorts in posting order. Edits keep the ts; that is how an edit finds its message.

A thread is not a separate object on the server: a reply is a message with thread_ts set to the parent's ts, and the parent carries a reply count, the latest reply's ts, and a few reply authors. We copy this shape. The client's message key is channelId + ":" + ts, and threads are keyed by the parent's key.

ts
type Ts = string; // "1729412345.000200": unique per conversation, sorts by time
type MsgKey = string; // `${channelId}:${ts}`

type ConvSummary = {
  id: string; kind: "channel" | "private" | "dm" | "group_dm";
  name: string; muted: boolean; memberCount: number;
  lastRead: Ts; latest: Ts; unread: number; mentions: number;
};
type Message = {
  channelId: string; ts: Ts; userId: string; blocks: RichText[];
  clientMsgId?: string; editedTs?: Ts; deleted?: boolean;
  threadTs?: Ts; // on a reply: the parent's ts
  replyCount?: number; latestReply?: Ts; replyUserIds?: string[]; // on a parent
  reactions: { emoji: string; count: number; mine: boolean }[];
  fileIds: string[];
};
// A run of history known to have no gaps inside it.
type Segment = { ts: Ts[]; reachedOldest: boolean; reachedLatest: boolean };

type WorkspaceStore = {
  users: Map<string, User>;
  summaries: Map<string, ConvSummary>; // every joined conversation
  messages: Map<MsgKey, Message>; // only for opened conversations
  segments: Map<string, Segment[]>; // per channel, or per thread parent
  files: Map<string, FileMeta>;
  outbox: PendingSend[];
};
The store for one workspace. Views hold keys, never copies of messages.

Segments: islands of history

A segment is a run of messages the client knows is complete: nothing exists on the server between its first and last ts that the client lacks. Opening a channel at the bottom creates one segment with reachedLatest: true, and scrolling up extends it.

Jumping to a message from March creates a second segment around it. When a page shows that two segments touch, they merge. The list renders one segment at a time, and at an edge that has not reached the oldest or the latest, it knows to fetch.

Segments make the hard cases honest. After a reconnect, if the gap fetch cannot cover the whole outage (more than, say, 1,000 messages were missed), the client does not pretend: it marks the latest segment as not reaching the latest, starts a new one at the bottom, and the list shows a "load newer" boundary where the hole is.

Where each piece lives

DataLives inWhy there
Workspace, channel, and user recordsStore in memory, mirrored to IndexedDBNeeded at every render; cached so the next start is instant
Conversation summaries (unread, mentions, last read)Store, mirrored to IndexedDB, refreshed from the server at bootThe sidebar needs them before anything else
Message windows (segments)Store for recently opened channels; the newest page of each mirrored to IndexedDBOpen channels render from memory; the cache gives instant paint and offline reading
DraftsStore, IndexedDB, and the serverMust survive reloads and follow the user to other devices
Pending sends (outbox)Store and IndexedDBA message typed offline must survive closing the app
Current workspace, channel, thread, jump targetURLShareable, back button, reload
Scroll anchors, collapsed sections, pane widthsMemory and IndexedDB (per device)Personal view state, not shared
Typing indicators and presenceMemory only, with expiryShort-lived; stale typing state is worse than none

Check your understanding · Exercise 3

Maya adds a thumbs-up reaction (:+1:) to a message that is visible both in #launch and as the parent in the open thread panel. What should happen in a normalized store? Select all that apply.

Boot and loading: a slim boot, lazy history, and a warm cache

When the app starts, what should it download before it can draw? The naive answer is "everything the user can see": all channels, all members, all users, and the latest messages of every channel, in one big response.

Slack's early API worked much like this: a call named rtm.start returned a full snapshot of the team, and the client opened its WebSocket after it. Slack's engineering blog describes what happened as teams grew: slow connects, a large memory footprint, expensive reconnects, and "reconnection storms" when a whole office came back online at once. The call is now retired: since September 2022, rtm.start returns only what rtm.connect does, a socket URL and a few ids.

History had the same problem. Slack's 2017 post "Making Slack faster by being lazy" explains that the client used to fetch recent messages for every channel at start, and changed to fetching only the open channel, with unread and mention counts from a separate counts call.

Decision What does the client load at start?

Full snapshot

One request returns every channel, member list, user, and recent messages.

The client then only applies socket events.

Pros

  • Simple: after boot, every lookup is local.
  • Search-as-you-type over users and channels works offline.

Cons

  • Size grows with the workspace, not with what the user does. 10 MB or more for large teams.
  • Every reconnect after a long sleep pays it again.
  • Most of it is never looked at.

Slim boot, lazy everything else

Boot returns the user, preferences, and a summary row per joined conversation, plus the users needed to name DMs.

History, member lists, and unknown users load on demand.

Pros

  • Size grows with the number of conversations the user is in, not the workspace: an estimated 100 to 200 KB before compression for 300 conversations.
  • Reconnects are cheap.

Cons

  • Every view must handle data that is not there yet.
  • Lookups of users or channels the client has never seen need a server query.

Cache first, then slim boot

Paint from the copy saved in IndexedDB last time, at once.

Then run the slim boot and reconcile.

Pros

  • Warm start paints in well under a second, before the network answers.
  • Works offline with what was cached.

Cons

  • For a few hundred milliseconds the screen can show stale counts.
  • A cache of private messages on disk needs care: clear it on sign-out, version it, and size it.

What we pick: Use the slim boot with lazy loading, and put the warm cache in front of it. Both are documented choices at Slack: its edge cache service (Flannel) was built so clients load minimal data up front and the rest on demand, and its 2019 desktop rebuild made every data access assume "a lazily loaded and incomplete data model". The same rebuild added warm boots: a Service Worker serves the app's files from its cache and a copy of the store saved in IndexedDB is used to draw right away, then fresh data is fetched.

The boot as a sequence diagram between the UI, IndexedDB, the API, and the socket. The UI reads the cache from IndexedDB and gets summaries and the last page back, so it paints at once. Then it sends GET /api/boot and the open channel's history to the API, and hello with resume_from to the socket, in parallel. The API returns fresh summaries and the page, which are reconciled. The socket answers hello_ok, and the client is live.UIIndexedDBAPISocketread cachesummaries, pageboot + historyhello, resume_fromfresh: reconcilehello_ok: live
The first paint comes from disk, not the network. The three network calls start together because the URL already names the channel, and the socket's hello_ok says whether the old cursor could resume.

The boot, step by step

  1. The shell loads, from the Service Worker cache on a warm start or the CDN on a cold one. The boot bundle holds the shell, the sidebar, and the list; emoji data, search, and settings load on demand.
  2. Paint from cache. Read summaries, users, and the open channel's newest page from IndexedDB (tens of milliseconds) and render.
  3. Three requests in parallel: the slim boot (in this design, GET /api/boot), the open channel's latest history page, and the socket. The URL already names the channel, so history does not wait for boot.
  4. Reconcile. Boot's summaries replace the cached ones; history merges into the cached segment by key; cached messages the server no longer has are removed.
  5. Go live. The socket's first frame confirms the event cursor, and events keep the store current.
  6. Fill in lazily: unknown users in batches, other channels when opened or hovered.

Unknown users: batch, and render a placeholder

With lazy users, a message can arrive from someone the client has never seen. Collect unknown ids for one task, send one batch request, and draw a neutral avatar until the records land, instead of 30 requests when a busy channel opens. Slack's blog describes the server helping too: its edge cache pushes a mentioned user's record to clients that lack it just before the message, so the client should accept user records arriving on the socket.

What to save to IndexedDB, and when

  • Save summaries, recently seen users, the newest 50 messages of the 20 most recent channels, drafts, the outbox, and the last cursor: an estimated 2 to 10 MB, well inside browser quotas.
  • Write small records keyed by id, batched in idle time (requestIdleCallback), never inside the event handler.
  • Store a schema version and the client build; on a mismatch, drop the cache and cold boot.
  • On sign-out, delete the workspace's database. It holds private messages, and shared computers are real.

What a desktop client adds

Slack's desktop app runs the web client inside Electron (Chromium plus Node.js). The data model stays the same, but the edges change: the process can keep the socket and notifications alive with the window closed, notifications and the dock badge go through the operating system, and the app can start at login and warm its caches early.

Because the process lives for days, memory matters even more. A fair interview line: "the desktop shell gives a longer-lived process and native integrations, but all product logic stays in the shared web code".

Check your understanding · Exercise 4

A user opens the app after a weekend with the laptop closed. The IndexedDB cache is from Friday. Which behaviors are right? Select all that apply.

Real-time: one socket per workspace, and events you can apply twice

The transport choice is the easy part, and the Chat app guide and the WebSockets lesson cover it: a busy team chat needs a server push channel with low overhead per event, and a WebSocket is the standard pick. The interesting questions for Slack are how many sockets, what travels on them, and how the client applies a stream of 20 to 50 events a second without janking or getting out of order.

Decision How many live connections does the client hold?

One socket per workspace

Each workspace store opens its own socket with its own token and its own event cursor.

Pros

  • Matches the store boundary: a workspace boots, reconnects, and catches up on its own.
  • A workspace in another region or on an enterprise grid can connect to its own edge.
  • One expired token or one server problem affects one workspace.

Cons

  • With 5 workspaces, 5 sockets and 5 heartbeats.
  • Total badges need a small layer above the stores.

One socket for all workspaces

A single connection carries events tagged with a workspace id.

Pros

  • One heartbeat, one reconnect.

Cons

  • One failure stalls every workspace.
  • The server must authorize many tokens on one connection; the catch-up logic now has several cursors in one stream.
  • Hard if workspaces live on different server clusters.

What we pick: One socket per workspace. It lines up with the per-workspace store, keeps failures local, and the cost of 2 to 5 idle sockets is small. It is also the unit Slack's desktop rebuild chose (see the shell chapter). Inside a workspace, the server decides what to send: summary-level events for every joined channel, and costly events (typing, presence) only for what the client asked about.

Sends go over HTTP; the socket is for hearing

A choice that surprises people: the client sends a new message with an HTTP request, not over the socket. Slack's engineering blog describes its client posting messages to its web API, which hands them to the servers that broadcast to every client in the channel, the sender included.

An HTTP request gives a clear answer for that one message (the server ts, or an error such as rate limited), works with retries and idempotency keys, and does not need the socket to be up at that instant. The socket is how the client hears everything, including its own messages from other devices.

This means the sender's message arrives twice: once in the HTTP response and once as a socket event. Both carry the client_msg_id the client generated, so the merge sees one message. Whichever arrives first replaces the optimistic copy; the second is a no-op.

The events

ts
type Envelope = { cursor: string; event: SlackEvent };

type SlackEvent =
  | { type: "message"; channel: string; ts: string; user: string;
      blocks: RichText[]; client_msg_id?: string; thread_ts?: string }
  | { type: "message_changed"; channel: string; ts: string;
      edited_ts: string; blocks: RichText[] }
  | { type: "message_deleted"; channel: string; ts: string }
  | { type: "reaction_added" | "reaction_removed"; channel: string;
      ts: string; emoji: string; user: string; count: number }
  | { type: "channel_marked"; channel: string; last_read: string;
      unread: number; mentions: number } // read state from another device
  | { type: "user_typing"; channel: string; user: string; thread_ts?: string }
  | { type: "presence_change"; users: string[]; presence: "active" | "away" }
  | { type: "user_change"; user: User }
  | { type: "member_joined_channel" | "member_left_channel"; channel: string; user: string }
  | { type: "channel_created" | "channel_archive"; channel: string };
Event frames. The type names follow Slack's public RTM and Events APIs; the fields and the cursor on every frame are this design's.

Two kinds of events travel on the same socket and deserve different treatment. State events (message, edit, delete, reaction, read marker, membership) change the store and must never be lost: the reconnect chapter is about getting every one of them.

Ephemeral events (typing, presence) describe the present moment. Losing one is fine, replaying an old one is wrong. They are never cached, never caught up after a drop, and they expire on their own (a typing indicator disappears 5 seconds after the last user_typing for that person).

The event pipeline. Socket frames go to decode, which parses them and records the cursor; unknown types are logged and skipped. Then a queue coalesces typing and presence and flushes once per animation frame. Then an idempotent reducer applies the batch as one store update, and only the changed rows in the views re-render. A note says typing and presence are never cached and expire on their own.socketframesdecodeparse, cursorqueuecoalesce typingreduceridempotentunknown type: skipflush per framestoreone updateviewschanged rows onlytyping, presence:never cached, expire
Bursts of events become at most one store update per frame. The reducer can safely see the same event twice, which is what makes replays and overlaps harmless later.

Ordering: by ts, not by arrival

The list sorts messages by ts, never by when they arrived. Arrival order and posting order differ more often than you expect: a gap fetch after a reconnect returns older messages after newer live ones, a slow upload's message lands after a later text, and the HTTP echo of your own send races the socket. Because each segment is a sorted array of ts values, inserting is a binary search, and late arrivals fall into place.

Edits and deletes need one more rule. An edit applies only if its edited_ts is newer than the one stored, so an old edit replayed after a reconnect cannot undo a newer one. A delete leaves a tombstone (the key with deleted: true) for the rest of the session, so a history page fetched a moment before the delete cannot bring the message back.

The golden rule: every handler is idempotent

If applying an event twice gives the same store as applying it once, then replays, overlaps between a gap fetch and the live stream, and the double arrival of your own messages are all harmless. Write each handler that way: upsert messages by key, set reaction state from the server's count and "mine" flag rather than adding one, move the read marker only forward, ignore edits older than the stored one. This rule turns the reconnect problem from "exactly once" (very hard) into "at least once, then dedupe" (easy).

Applying 50 events a second without jank

Each socket frame is its own task, so 50 events that each update the store mean 50 renders. Instead, queue events and flush once per animation frame (one store update, at most 60 times a second), coalescing typing and presence in the queue to the latest per user and channel. Workspaces not on screen update their store but render nothing except the rail badge. Unknown event types are logged and skipped, because the server adds new ones while old clients run.

Check your understanding · Exercise 5

After a reconnect, the client receives these in this order for #ops: an edit of message 100.000300 with edited_ts 105, a gap-fetch page containing message 100.000300 with edited_ts 108, then a duplicate of the edit with edited_ts 105. What should the message show? Select all that apply.

Reconnect, catch up, and the outbox

The socket will drop: a laptop lid closes for lunch, a VPN reconnects, a phone hotspot switches cells, or the server deploys and closes connections on purpose. For an app open 9 hours a day, 10 to 30 drops a day is a fair estimate. Each drop leaves a hole in the event stream, and the store is only correct if the client fills that hole exactly. This chapter has three parts: noticing the drop, catching up, and handling what the user did while offline.

Noticing the drop

The close event is often late: after a laptop sleeps, a dead TCP connection can look open for minutes. So the client also sends a small ping every 30 seconds and treats two missed pongs as a dead socket, checks the socket at once when a timer notices the machine slept or the tab comes back (visibilitychange), and uses the online event as a hint to retry now. The Chat app guide and the WebSockets lesson cover heartbeats in more depth.

After a drop, reconnect with exponential backoff and jitter: wait about 1 second, then 2, 4, 8, up to a cap near 30 seconds, each time picking a random point in the window (full jitter, as in the WebSockets lesson). Jitter matters for Slack in particular: when an office's network comes back, thousands of clients reconnect at the same second.

Slack's engineering blog names these reconnection storms as one of the costs that pushed it toward lighter reconnects. Retry at once on the online event, and reset the backoff after a connection has stayed up for a minute.

The connection state machine. Connecting goes to catching up on hello_ok. Catching up, which replays from the cursor, goes to live when caught up, or to full resync when the cursor is too old; full resync, which reruns boot plus a per-channel delta, then goes to live. Live goes to dropped after 2 missed pongs or a close. Dropped goes to backoff, which waits 1, 2, 4 seconds and more with jitter, then returns to connecting when the timer fires.connectingcatching upreplay from cursorlivebackoff1s, 2s, 4s + jitterfull resyncboot + deltadroppedhello_okcaught uptoo old2 missed pongssocket closed or deadtimer fires
Every drop goes through backoff and catch-up, never straight to live. Live is only reached after the hole is filled, by replay or by a full resync.

Catching up: filling the hole

Decision After the socket is back, how does the client learn what it missed?

Reload everything

Run the boot again and refetch the latest page of every open channel.

Pros

  • Simple and always correct for what it reloads.

Cons

  • Heavy after every short drop: 10 to 30 times a day, for every user, at the same moment as everyone else.
  • Edits and deletes in older, already-loaded messages are missed unless those pages reload too.
  • Scroll positions jump as windows are replaced.

Resume the stream from a cursor

Every event carries a cursor. On reconnect, the client sends the last cursor it applied.

The server replays the events after it from a short log (minutes to hours), then goes live.

Pros

  • Exact: edits, deletes, reactions, read markers, all of it.
  • Cheap for the common short drop: a few dozen events.

Cons

  • Only works while the server still has the events. After a long sleep, the cursor is too old.
  • The server must keep a per-workspace event log.

Per-channel delta from timestamps

Compare the fresh boot summaries with the store: any channel whose latest moved has new messages.

For channels that are open, fetch history newer than the last ts held. Others just update their counts.

Pros

  • Works after any length of time.
  • Only touches what changed.

Cons

  • Misses edits, deletes, and reactions on messages older than the last ts held, unless the server also offers a "changed since" query.
  • Many requests if many open channels changed.

What we pick: Use the cursor resume for drops within the server's replay window, which covers most drops in a day. When the server answers "cursor too old", fall back to the delta: re-run the slim boot for fresh summaries, refetch the newest page of each channel in memory, and mark every segment that might have a hole as not reaching the latest, so the list loads the missing part if the user scrolls into it. Never stitch a guess across a hole.

One more detail makes catch-up correct: live events keep arriving while the catch-up runs. Buffer them, apply the catch-up result, then apply the buffer; some events will be in both. Because every handler is idempotent (previous chapter), the overlap is harmless: the second copy of a message upserts the same key. The simulator below lets you drop the connection, post messages while it is down, and watch what each catch-up strategy produces.

On reconnect
Server and client copies of a channel. The server has 3 messages. The client is connected at cursor 3, and 0 of its messages are missing or stale. Duplicates dropped so far: 0. Strategy: resume.serverclient112233socket liveclient cursor: 3server keeps last 5 eventsmissing or stale: 0green: matches serverhatched: stale editduplicates dropped: 0
Connected. Drop the socket, post a few messages, then reconnect.

Messages written while offline: the outbox

The composer keeps working when the connection is down. Enter creates a pending message with a client id (from crypto.randomUUID()), shows it at once in a dimmed style, and adds it to the outbox, a queue saved to IndexedDB so it survives closing the app. The Optimistic updates and Offline-first lessons and the Chat app guide cover the pattern; for Slack, the details that matter are:

  • One in flight per conversation. If two messages to #ops go out in parallel, the second can get an earlier ts and the conversation reads out of order. Send each conversation's queue in order; different conversations can run in parallel.
  • Retries as in the Chat app guide. Every retry carries the same client id, so the server returns the existing message instead of posting twice. Network errors and 5xx answers back off, a 429 waits for its Retry-After, and a permanent error (the channel was archived, the user left it, the message is too long) stops at once and shows "Not sent" with Retry, Edit, and Delete.
  • Merge on confirm. The server reply carries the real ts. The pending record is replaced by the confirmed one under its real key, and the list keeps the row in place (the row's React key is the client id, so it does not remount).
Sending a message as a sequence diagram between the composer, the store, the outbox, the API, and the socket. The user presses Enter: the composer adds a pending row to the store, which enqueues it in the outbox. The outbox posts to the API, and the request times out. The outbox retries with the same client id, and the API answers 200 with the ts. The socket then delivers the echo of the same message to the store, which dedupes it by client id, and the row shows as confirmed.ComposerStoreOutboxAPISocketpending rowenqueuePOSTtimeoutretry, same id200, tsecho: dedupeconfirmed
The failure path is the normal path with one more arrow. Because the retry carries the same client_msg_id, the server cannot post it twice, and the socket echo merges into the same row.

Check your understanding · Exercise 6

A user's laptop sleeps for 6 hours. The server keeps 2 hours of events for cursor resume. On wake, what should the client do? Select all that apply.

Unread state and mentions: what the user really saw

Bold channel names and red badges are how people decide where to look next, so they have to be right. The model is small: each conversation has a last read marker, the ts of the newest message the user has read. Every message with a larger ts is unread, except the user's own.

A mention is an unread message that names the user, their user group, @here or @channel (unless they turned those off for that channel), or any message in a DM. The sidebar shows a bold name for unread and a number for mentions. A muted channel is never bold, but in Slack a direct mention there still shows its badge, without a notification.

This is a watermark, not a per-message flag. One ts per conversation per user is enough for 50,000-member channels, while per-message receipts (who read what) would be 50,000 rows per message. It is the same idea as the read watermarks in the Chat app guide, applied to the reader instead of the sender.

Who counts

The client cannot count unread messages itself: for most channels it has no history. So the server sends counts in the boot summaries and with every read-state event, and the client only adjusts them for live events (a new message from someone else above the marker adds one, and one more mention if it names the user). Whenever the server's number arrives, it wins.

When is a message read?

Decision When should the client move the last read marker?

When the channel is opened (Slack's default)

Opening a channel sets the marker to its latest message.

The list still opens at the first unread, under a frozen "New" line, so the reader sees where unread began during this visit.

Pros

  • Simple and predictable: one mark request per open, nothing to tune.
  • Fast triage: clicking through channels clears them, which many people want.

Cons

  • Open a channel with 80 unread messages, glance at the bottom 12, switch away: 68 messages are marked read that nobody saw.
  • Clicking through channels to look for one thing clears all of them, read or not.

When the list is scrolled to the bottom

Mark everything read once the user reaches the newest message.

Pros

  • Closer to reality for short catch-ups.

Cons

  • Still wrong for long jumps: dragging the scrollbar to the bottom skips everything in between.
  • Says nothing about a window in the background or a tab hidden behind another app.

When each message has actually been on screen

Watch message rows with an IntersectionObserver. A row counts as seen when it is mostly visible, the page is visible, and the window has focus.

The marker moves forward through the unbroken run of seen messages after it, and is sent to the server in batches.

Pros

  • Matches what the user could have read.
  • Works the same for scroll, jump, and live arrivals.

Cons

  • More code, and the rules (how visible, for how long) need tuning.
  • A user who scrolls very fast still "sees" everything; we accept that.
  • One marker cannot describe a hole: after a jump to the bottom, the messages seen there stay unread too, until the skipped ones are read or the user marks the channel read.

What we pick: Make the rule a preference, as Slack does, and pick the default from the requirements. Slack's "Mark as read" setting offers "Start me where I left off, and mark the channel read" (the default), "Start me at the newest message, and mark the channel read", and "Start me at the newest message, but leave unseen messages unread". This design asked for honest unread state, so it defaults to the seen rule; a product that puts fast triage first would default to marking on open. Either way, add explicit actions: Slack uses Esc to mark the current channel read, Shift+Esc to mark everything read, and Alt+click to mark a message unread.

ts
const seen = new Set<Ts>(); // rows seen in a focused window since the channel opened
const io = new IntersectionObserver((entries) => {
  if (document.visibilityState !== "visible" || !document.hasFocus()) return;
  for (const e of entries) {
    if (e.isIntersecting && e.intersectionRatio >= 0.6) seen.add((e.target as HTMLElement).dataset.ts!);
  }
  scheduleMark();
}, { root: listEl, threshold: [0.6] });

// Walk forward from the marker while each message was seen. A skipped one stops the walk.
function nextMarker(lastRead: Ts, loaded: readonly Ts[]): Ts {
  let marker = lastRead;
  for (const ts of loaded) {
    if (compareTs(ts, marker) <= 0) continue;
    if (!seen.has(ts)) break;
    marker = ts;
  }
  return marker;
}

function scheduleMark() { // debounced: at most one request per 1.5 s
  clearTimeout(markTimer);
  markTimer = setTimeout(() => {
    const marker = nextMarker(summary.lastRead, segment.ts);
    if (marker === summary.lastRead) return;
    store.markRead(channelId, marker); // optimistic: the badge updates now
    api.mark({ channel: channelId, ts: marker });
  }, 1500);
}
Seen-tracking for the message list. Rows carry their ts in a data attribute.

Note the walk in nextMarker: the marker moves only through an unbroken run of seen messages. Jump from the first unread to the bottom, and the skipped messages stay unread, and so do the ones seen at the bottom, because one marker cannot describe a hole. The list then shows a bar with "Jump to first unread" and "Mark as read", and Esc marks the channel read.

A few more rules. When the window regains focus, re-check what is on screen, since the observer only fires on changes. Very tall messages count as seen once their bottom edge was visible, because 60% may never fit. And with virtualization, rows are observed on mount and unobserved on unmount.

Mark read
A channel of 15 messages. Messages 5 to 8 are on screen, and the window is focused. Rule: seen. Last read is 8, so 7 are unread. The New line sits after message 4. Marked read but never seen: 0.on screen123456789101112131415NewlastReadunread badge: 7window: focusedread, never seen: 0red cells: lost to the user
The channel opened at the first unread message, below the New line. Scroll and watch the marker.

The "new messages" divider

When a channel with unread messages opens, a red "New" line sits above the first unread message, and the list opens there if the unread messages do not fit on one screen. The line's position is a snapshot taken at open: it does not move while the user reads, even though the marker moves under it, or it would slide away and lose their place. It goes away after they leave and come back with nothing unread.

Syncing read state across devices

The mark request goes to the server, which stores the marker and broadcasts channel_marked (with fresh counts) to the user's other sessions, which update their sidebars and close notifications for that channel. Because the marker only moves forward, two devices racing resolve to the larger one. Debounce the request (1 to 2 seconds), and flush it at once when the page becomes hidden or the user leaves the channel, so the phone is right by the time they pick it up.

Read state sync as a sequence diagram between the laptop, the server, and the phone. The laptop posts mark for launch at ts 1205 after its debounce. The server replies unread 0, mentions 0. The server sends channel_marked to the phone, which clears its badge and notification. The phone, which was behind, then sends a mark at the older ts 1180. The server keeps 1205, because the marker only moves forward, and tells the phone so.LaptopServerPhonemark launch: 1205unread 0, mentions 0channel_marked: 1205mark launch: 1180kept 1205: forward only
One marker per user per conversation, stored on the server, moved only forward. Late or out-of-order marks from slower devices cannot undo what the user has already read.

Threads get the same model one level down: each thread the user follows has its own marker, and replies count in a separate "Threads" view rather than in the channel's badge, unless the reply was also sent to the channel.

Check your understanding · Exercise 7

Under this design's default rule (mark what was seen), which of these should move the last read marker of #launch forward? Select all that apply.

The message list: years of history in a window

The message list is where the user spends their day. It loads older messages as you scroll up without the content jumping, opens at a message from two years ago and loads both ways from there, renders rows of very different heights at 60 frames a second, and draws date dividers and grouped messages as derived decoration. The Virtualization lesson covers rendering only visible rows; this chapter is about what Slack's list adds.

Loading older messages without a jump

Put an invisible sentinel element near the top of the list and watch it with an IntersectionObserver with a rootMargin of about one and a half screens. When it comes close, request the page before the segment's oldest ts (50 messages is a good size: about 25 to 50 KB, enough for several screens). The trouble starts when the page arrives. Inserting 50 rows above the viewport pushes everything down, and the message the user was reading jumps off screen.

Decision How do we keep the reader's place when content is inserted above?

Browser scroll anchoring

Modern browsers can adjust scrollTop themselves when content above the viewport changes size (the overflow-anchor CSS property controls it).

Pros

  • No code when it works.

Cons

  • Chromium and Firefox have supported it for years, but Safari added it only in Safari 27 (September 2026), so older Macs and iPhones get no help.
  • A virtual list swaps and positions rows itself, which the browser's choice of anchor does not understand.

Reverse the flow with CSS

flex-direction: column-reverse on the scroller makes the bottom the starting edge, so adding content at the top does not move the bottom.

Pros

  • Sticking to the bottom for new messages comes for free.

Cons

  • Scroll math is reversed and has had cross-browser quirks.
  • Keyboard order and screen reader order must be fixed by hand, since the DOM order is reversed.

Measure and correct in code

Before inserting, record an anchor: the key of the top visible message and its distance from the top of the viewport.

After the new rows render (in a layout effect, before paint), find the anchor again and set scrollTop so its distance is the same.

Pros

  • Exact in every browser, and the same code handles every case of content changing above: older pages, images loading, link previews, edits.

Cons

  • Must run before paint, or the user sees a one-frame jump.
  • Needs measured heights of rows above the anchor, which a virtual list keeps anyway.

What we pick: Measure and correct in code, through the virtual list's own anchor support if the library offers it. The list keeps a single "anchor" concept (message key plus offset) that is used for prepending pages, for restoring a channel's position on return, for keeping place when a row above changes height, and for jump-to-message. One idea, four uses.

Two windows after an older page of 50 messages is inserted above the viewport. Left, with no correction: the viewport now shows older 49 and older 50, and the message the user was reading, Maya: ship it?, has been pushed down out of place. Right, with anchor correction: the anchor was the key of Maya's message plus its 60 pixel offset, scrollTop grew by the added height, and Maya's message is exactly where it was, with Sam: tests green below it.no correctionolder 49older 50Maya: ship it?reader lost their place50 rows pushed it downwith anchorolder page aboveMaya: ship it?Sam: tests greenanchor: key + 60 pxscrollTop += added height
The anchor is a message key plus an offset, not a pixel position. After the rows render and before paint, the list moves scrollTop by exactly the height that was added above.

New messages at the bottom

If the user is at the bottom (within about 50 pixels), a new message keeps them there: the list scrolls to show it. If they have scrolled up to read, it must not move them: a "3 new messages" pill appears at the bottom, and clicking it scrolls down. This is the same stickiness rule as the LLM chat UI guide, and it uses the same "was the user at the bottom before this change?" check, made before the new row renders.

Jumping to a message

A search result, a permalink pasted in another channel, "jump to first unread", or a reply's "view in channel" all ask for the same thing: show message X in channel C, centered and highlighted. The Chat app guide walks through the same flow with sequence numbers; with ts values and segments, the steps are:

  1. Put the target in the URL (?msg=1698765432.001200) so the back button returns to where the user was before the jump.
  2. Look for a segment of C that already contains the target ts. If one exists, render it and scroll to the row.
  3. If not, fetch a page around the target: about 25 messages older and 25 newer, in one request (or two requests with latest and oldest set to the target). Store it as a new segment that reaches neither the oldest nor the latest.
  4. Render that segment with the anchor on the target row, centered. Highlight the row for 2 to 3 seconds and move keyboard focus to it, so keyboard and screen reader users land in the same place as everyone else.
  5. As the user scrolls down, load newer pages. When a newer page contains a message already in the live segment, the two segments merge, and the list is back to normal. Until then, show a "Jump to recent messages" button.
A channel's history as a line from older on the left to newer on the right. A jump to a target ts creates a segment of 50 messages around it, marked active. Between it and the live segment of the newest 200 messages there is a gap that is not loaded. After the user scrolls down and newer pages load, the two meet and merge into one segment that reaches the latest.target tsjump: 50gap: not loadedlive: 200oldernewerafter scrolling down and loading newer pages:one segment, reaches latest
The client never pretends the gap is not there. Each island is complete inside, the list renders one island at a time, and two islands merge only when a page proves they touch.

Variable heights, measured and remembered

Slack messages range from a single short line to many screens tall. Start from an estimate by type (text lines at the current width, an image's size from its file metadata, a code block's line count), then measure each rendered row with a ResizeObserver and cache the height by message key and width bucket.

A row above the anchor whose real height differs from its estimate triggers the same anchor correction, so nothing visibly moves. Images reserve their box with width and height (or aspect-ratio), so a slow image never shifts the text.

Date dividers and grouping are derived

Date dividers ("Today", "Monday, October 12th") and compact rows (a second message from the same person within about 5 minutes, drawn without avatar and name) are not stored. They are computed from the previous row at render time: a divider where the local dates differ (in the user's time zone, with Intl.DateTimeFormat), a compact row when the author matches, the gap is short, and no divider or "New" line sits between.

Inserting a message recomputes two rows, not the list. Always compute from the sorted segment: a late message from a gap fetch can split a group.

Check your understanding · Exercise 8

A user clicks a search result from 14 months ago in #eng. The store has only the live segment of #eng (the last 200 messages). What should happen? Select all that apply.

The composer: mentions, commands, drafts, and uploads

The composer is a rich text editor, and the Rich text editor guide covers how to build one (a document model, beforeinput events, selection, and IME composition for languages like Japanese). Here we focus on what Slack's composer adds around the editor: tokens that point at people and channels, a command line hidden in a text box, drafts that follow the user, and uploads that start before the user presses Enter.

What the composer produces

The composer never sends HTML. It sends a structured document: blocks (paragraphs, lists, quotes, code) holding styled text runs, plus tokens for mentions, channels, emoji, and links. A mention token holds the user id, not the name: in Slack's text format a mention looks like <@U024BE7LH>, rendered with the person's current name. Renames never break old messages, the server finds mentions without parsing names, and the receiver builds DOM from known block types instead of setting innerHTML.

Autocomplete for @, #, :, and /

  • @ people. Rank channel members first, then frequent contacts, then everyone. A big workspace is not all in the store, so search local users at once and the server in parallel (debounced, with stale requests aborted; see the Request races lesson), and append server results without moving what is under the cursor.
  • @channel and @here in big channels. Before sending, warn with the real number: "This will notify 12,431 people in 4 time zones." The client knows the member count from the summary. Admins can restrict who may use them.
  • # channels search the summaries (all joined channels are local) and then the server for public channels the user has not joined.
  • : emoji opens after two characters (:th) to avoid popping up on every colon. Standard emoji data ships as a lazy chunk; a workspace's custom emoji (often thousands) load once and are cached in IndexedDB.
  • / slash commands only at the start of the message. The menu lists built-in commands and those added by installed apps, with their argument hints. A command is not posted as a message: it goes to the server (and maybe to an app), and the answer comes back as a message only the user can see, or a dialog.

Drafts per conversation, on every device

Every conversation and thread has its own draft that survives switching, reloads, and devices. It is saved in three steps of rising cost: to the store on every change, to IndexedDB about 300 ms after typing stops, and to the server about 2 seconds after typing stops (and at once when the user leaves the conversation or the page is hidden). The server broadcasts draft changes to the user's other sessions, so the sidebar can show a pencil on conversations with drafts.

The conflict case: the user typed on the phone, then opens the laptop where an older draft of the same conversation sits. The rule is simple and safe: the newest save wins, unless the local composer is focused and has unsaved changes, in which case the local text stays and the remote one is ignored. Never overwrite text under someone's cursor. When a message is sent, its draft is deleted everywhere.

Uploads that start before Enter

When a user drops a 40 MB video into the composer, the upload should start right away, while they type the message that goes with it. By the time they press Enter, the file is often already on the server and the message sends at once. Slack's current public API follows this shape too: get an upload URL, send the bytes there, then complete the upload to share the file in a conversation. In the client, each attachment is a small state machine.

The upload state machine for one attachment. Picked goes to uploading, which shows XHR progress. Uploading goes to uploaded, which has a file_id, at 100 percent. Uploaded goes to shared, in a message, when the message is sent. Uploading can go to failed on a network or 5xx error, and failed goes back to uploading on retry of the chunk. Uploading can also go to cancelled, which aborts and discards. A note says that pressing Enter while uploading makes the message wait in the outbox.100%sentpickeduploadingXHR progressuploadedfile_idsharedin a messagefailednetwork, 5xxcancelledabort, discarderrorretryremoveEnter while uploading: the message waits in the outbox
The upload starts the moment a file is picked, not when Enter is pressed. A failure retries one chunk, and a cancel aborts the request and tells the server to drop the partial file.
  • Progress. fetch does not report upload progress in all browsers, so use XMLHttpRequest and its upload.onprogress event for the progress bar, or upload in chunks with fetch and count chunks.
  • Big files. Above a few MB, upload in chunks of a few MB each, so a dropped connection costs one chunk, not the whole file. The Resumable upload problem goes deeper.
  • Preview. Show an image or video thumbnail from the local file at once (URL.createObjectURL), and revoke the URL when the attachment is removed or sent, or it leaks memory.
  • Send before done. If the user presses Enter while an upload is at 60%, the message goes into the outbox in a "waiting for files" state and posts when the upload completes. The composer is free for the next message.
  • Cancel and remove. Removing an attachment aborts its request and tells the server to discard the partial upload.

Editing and deleting

Pressing the up arrow in an empty composer edits your last message, a shortcut many Slack users rely on. Saving is optimistic: the new text shows with "(edited)" and rolls back if the server refuses (some workspaces limit editing to a time window). Deleting asks first, then removes the row and leaves a tombstone; a deleted thread parent stays as "This message was deleted" so its replies keep a home.

Check your understanding · Exercise 9

A user writes "@maya can you check this" and attaches a 200 MB screen recording, then presses Enter while the upload is at 35%. What should the client do? Select all that apply.

Large channels: members and presence, only what is visible

A company-wide channel with 40,000 members breaks every feature that assumes "all members". The client cannot download the member list: at an estimated 500 bytes per user record, that is 20 MB.

Nor can it receive a presence event every time any of them goes active or away (with 40,000 people that can be dozens of events a second, all day), and a reaction with 2,000 people on it cannot list them all in a tooltip. The fix is the same everywhere: work on pages, and only for what is on screen.

Member lists on demand

  • The header shows the member count from the summary. Nothing else about members loads until asked.
  • The members panel is a virtual list that fetches pages of about 100 members with a cursor as the user scrolls. Filtering by name searches on the server, not in the loaded pages.
  • "Who reacted" shows the first few names the event carried and "and 1,203 others"; the full list loads only if the user opens it.
  • Mention autocomplete ranks people active in the channel recently (known from the loaded messages) before asking the server.
  • Join and leave messages are hidden by default in big channels: at 40,000 members they would drown the conversation.

Presence: subscribe to what you can see

Presence (the green dot) is the classic trap. The naive design subscribes to every user the client knows. Slack's engineering blog describes the opposite: a client receives presence notifications only for users visible on screen, and its Flannel post reports that moving presence to subscriptions cut the presence events clients receive by a factor of 5.

We copy that. Every component that draws a presence dot (DM rows, avatars of visible messages, the open profile, visible member rows) registers interest while mounted. A presence subscription manager keeps a reference count per user id and, about once a second, sends the server the set of ids with a count above zero. The server answers with their current status and then pushes changes only for them.

Presence subscriptions. A window shows three visible member rows, Maya, Sam, and Ana, which register with the presence manager; 2,000 rows that were scrolled past are not subscribed. The presence manager keeps reference counts and sends presence_sub with 3 ids over the socket, once a second. The server pushes status changes for those 3 users only. A note says 38,000 members, 3 subscribed.membersMayaSamAna2,000 scrolledpast: nonepresence mgrref counts, 1/ssocketpresence_sub: 3 idsserverchanges for 3register38,000 members3 subscribed
Rows register while mounted; the manager sends the final set once a second, so a fast scroll never reaches the server. This matches what Slack describes: a client gets presence only for users visible on screen.
  • Batch the changes. A fast scroll mounts and unmounts hundreds of rows. Sending one frame per second with the final set, rather than one per change, keeps the traffic small. The Presence and cursors lesson covers the general pattern.
  • Keep a short grace period. When a row scrolls away, keep its user in the set for a few seconds, so scrolling back and forth does not churn.
  • Cap the set. A few hundred ids is plenty for one screen. If a view would ask for more, it is asking for too much.
  • Show unknown as unknown. Before the server's answer, draw no dot (or a hollow one), not a stale green dot from the cache. Old presence is wrong presence.

Typing in a busy channel

In a 5-person channel, "Maya is typing" is useful. In a 40,000-person channel during an all-hands, 30 people may be typing at once. The client shows at most two names and then "Several people are typing", and the server is free to drop typing events for very large channels altogether. On the sending side, the client sends a typing event at most once every few seconds while keys are pressed, never one per key.

Check your understanding · Exercise 10

The members panel of #announcements (38,000 members) is open and the user scrolls fast through 2,000 rows. Which designs keep presence cheap? Select all that apply.

Notifications: the right message, once, on the right device

Notifications are where a chat app can most easily become noise. A user in 150 channels must hear about the DM from their manager and the mention in an incident channel, and nothing else. They must hear about it once, not on the laptop, the phone, and two browser tabs at the same time, and never about the channel they are reading right now. The Push notifications lesson covers the browser APIs; this chapter is about the rules and where each rule runs.

The rules a user controls

  • Per conversation: all new messages, mentions only (the default for channels), or nothing. Muting a channel turns off its notifications and greys it in the sidebar.
  • DMs and mentions notify by default, plus any keywords the user listed (their team's name, a project code).
  • Threads notify for replies in threads the user follows.
  • Do not disturb: a daily schedule and a manual snooze ("pause for 1 hour"). During it, nothing makes a sound or a banner.
  • Across devices: Slack lets users choose to send mobile notifications only when they are inactive on the desktop.

Decision Who decides whether a message produces a notification?

The client decides everything

Each client receives every message event and applies the user's preferences itself.

Pros

  • Full local context: it knows which channel is open and whether the window is focused.

Cons

  • Clients that are not running (a closed laptop, a phone) cannot decide anything.
  • Every client must hold every preference and keyword, and they can disagree.

The server decides everything

The server applies preferences and sends a push to each device that should ring.

Pros

  • Works with no client running. One place holds the rules.

Cons

  • The server does not know that this exact window is focused on that exact channel right now, so it notifies about the conversation the user is reading.

Server decides who should hear; the client decides whether to ring now

The server applies preferences, keywords, DND, and mentions, and flags each event with "notify this user" (and why).

A running desktop client then applies local rules: suppress if the user is looking at that conversation, show one banner per conversation, play a sound or not.

Devices with no running client get a push.

Pros

  • Rules live on the server; the moment-to-moment context lives on the client.
  • Clients stay simple: they do not need every preference.

Cons

  • Two places to reason about when debugging "why did I (not) get notified?".

What we pick: Split it: the server decides whether this user should hear about this message, and the client in front of the user decides whether to show it now. That way a muted channel never rings, and the channel you are reading never rings either.

The notification decision. On the server: a message event goes through apply prefs, which checks mute, do not disturb, and keywords, and becomes flag: notify. On the client: first, is the user reading it, meaning the conversation is open, visible, and focused? If yes, skip, because it is in front of the user. If no, is this the leader tab? If no, skip, because another tab shows it. If yes, show a banner with tag set to the channel.Servermessage eventapply prefsmute, DND, keywordsflag: notifyClientreading it?open, visible, focusleader tab?Web Locksshow bannertag = channelskipin front of userskipanother tab showsyesnonoyes
Rules that need settings live on the server; rules that need the screen live on the client. Each check can only remove a notification, never add one.

Showing it on the desktop

  • Ask for permission at the right time. Call Notification.requestPermission() from a click on a clear prompt ("Turn on desktop notifications"), never on page load. Firefox and Safari only show the prompt from a user gesture, Chrome can move sites that ask too early to a quieter prompt, and a denied permission is hard to win back.
  • Suppress what the user is reading. If the event's conversation is open, document.visibilityState is "visible", and document.hasFocus() is true, skip the banner. A visible but unfocused window still notifies.
  • One banner per conversation. Create notifications with tag set to the conversation id. A new one with the same tag replaces the old one instead of stacking ten banners for a busy DM.
  • Close what was read. When a channel_marked event arrives (from this device or any other), close the open notification for that conversation. The phone reading the DM clears the laptop's banner.
  • Click goes to the message. On click, focus the window (window.focus()), open the conversation, and jump to the message.
  • One tab rings. With several tabs open, only a leader tab, elected with the Web Locks API, shows notifications.

When no window is open: push

A browser user who closed the tab can still get notifications through Web Push: the service worker subscribes with pushManager.subscribe({ userVisibleOnly: true, ... }), the server sends the flagged event to the push service, and the worker's push handler calls self.registration.showNotification(). Chrome expects every push to show a notification (that is what userVisibleOnly promises), so the server must only push things that should be shown; if the worker shows nothing, Chrome shows a generic notification of its own.

The desktop app does not need Web Push: its process keeps the socket open with the window closed and shows native notifications through Electron.

Badges: the number on the tab and the dock

The badge should mean "someone wants you", so it counts mentions and DMs across all workspaces, not every unread message (which would sit at 300 all day). In a tab, put the number in the title ("(3) Slack") and swap the favicon. An installed web app can use the Badging API (navigator.setAppBadge(3), supported in Chromium-based desktop browsers and in Safari for web apps added to the Dock or Home Screen), while the desktop app sets the dock badge natively. All of them read one total from the account layer.

Check your understanding · Exercise 12

Priya is reading #incidents in a focused window when a message mentioning her arrives there. At the same moment a DM from her manager arrives. What should her laptop do? Select all that apply.

Putting it together: the architecture

We now have all the parts. This chapter puts them in boxes and draws the lines between them, and, more important than the boxes, says why each boundary sits where it does. In the interview, this is the diagram you draw around minute 10, then fill in as you go deeper.

The client's architecture. In the browser: Views (sidebar, list, composer) read the Workspace store through selectors and send intents to it. A Service worker serves app files from the CDN. Trackers for read state, presence, and notifications sit beside the store. The Sync engine, holding the socket, the outbox, and the API client, writes server events into the store and talks to the web API over HTTP and the gateway over a WebSocket. Persistence writes the store to IndexedDB in idle time. A note says there is one store and socket per workspace, with an account layer above for badges.Browser (one window)viewssidebar, list, composerservice workercached app filesworkspace storenormalized, segmentstrackersread, presence, notifypersistenceIndexedDB, idle writessync enginesocket, outbox, API clientone store + one socket per workspace,account layer above for badgesServersCDNapp filesweb APIHTTPgatewayWebSocketselectorsintents
Views never call the network. The sync engine is the only writer of server data into the store, so ordering and dedupe live in one place; persistence and trackers hang off the store.
PartResponsibilityWhy it is separate
App shell and routerLayout, the URL (workspace, channel, thread, jump target), lazy loading of big featuresThe URL is the one source of truth for "where am I"; nothing else writes it
Account layerSigned-in user, workspace list, total badge, notification leader, preferences shared across workspacesHolds the few things that span workspaces, so the stores never reach into each other
Workspace store (one per workspace)Users, summaries, messages, segments, drafts, outbox, connection stateThe unit of isolation: boot, pause, or drop a workspace as one piece
Sync engine (one per workspace)Socket, heartbeat, backoff, event queue, catch-up from a cursorThe only writer of server events into the store, so ordering and dedupe live in one place
API clientHTTP calls with auth, retries, idempotency keys, AbortController, rate-limit handlingOne place to change auth or add tracing
Outbox senderDrains pending sends and uploads in order per conversationSends must survive reloads and offline; the composer just enqueues
PersistenceWrites store changes to IndexedDB in idle-time batches; reads them at bootKeeps disk work off the event path
Read trackerWatches rows, moves the last read marker, debounces mark requestsRead rules are subtle; keeping them out of the list component keeps both simple
Presence managerReference counts visible users, sends the subscription set once a secondTurns hundreds of mounts into one frame a second
Notification controllerApplies local rules to flagged events, shows and closes notifications, sets badgesNeeds focus and route state that the store should not know about
Service workerCaches app files per deploy for warm boot; receives Web PushRuns when no page is open
ViewsSidebar, message pane, composer, side panel, search, quick switcherRead from selectors, send intents; never call the network themselves

How data flows

Reads flow one way: server to network to the sync engine or API client, into the store, out through selectors to the views. Writes flow the other way, through one path each: the composer enqueues a message in the outbox, the outbox sender posts it, the server answers and also broadcasts it, and the broadcast comes back through the sync engine like anyone else's message. Because the user's own messages take the same road home as everyone else's, there is one code path for showing a message, not two.

Data flow for the main screen. Reads, left to right: the server (web API and gateway) sends pages and events through the edge (CDN and TLS) as frames to the sync engine, which dedupes and orders them and writes events into the normalized store. The store feeds the sidebar row and the message list through selectors. Writes go the other way: the composer enqueues into the outbox in IndexedDB, which posts with a client id to the server. Notes: the user's own message returns as an event like anyone else's; the store is cached to IndexedDB and the next boot reads it first.pagesframeseventsserverAPI, gatewayedgeCDN, TLSsync enginededupe, orderstorenormalizedoutboxIndexedDBcomposermessage listsidebar rowselectenqueuePOST + client idown message: comes backas a socket eventstore cached to IndexedDB,read first at next boot
Reads and writes each have one road. The user's own message goes out through the outbox and comes back through the sync engine, so there is one way to show a message, not two.

Should any of this run in a worker?

At 20 to 50 events a second, parsing frames, applying events, and writing IndexedDB on the main thread is usually fine: each event is a fraction of a millisecond. Moving the sync engine into a Web Worker or SharedWorker frees the main thread and lets tabs share one socket, at the cost of copying data between threads. A good answer: "start on the main thread, watch long tasks in production, and move the sync engine to a worker if events show up in them".

Think first

Why should the message list never call fetch itself, even to load an older page?

The interface: HTTP calls, socket frames, and components

The interface has three layers: HTTP endpoints for anything with a clear answer (boot, history, send, mark, search, upload), socket frames for the live stream, and the props of the main components. The shapes below are this design's, written close to the style of Slack's public Web API (an ok flag and a short error code in every response) so they read naturally to anyone who has used it.

http
GET  /api/boot                          -> self, prefs, summaries[], dm users[], socket url, cursor
GET  /api/history?channel=C1&before=TS&limit=50   -> older page
GET  /api/history?channel=C1&after=TS&limit=50    -> newer page
GET  /api/history?channel=C1&around=TS&limit=50   -> page centered on TS
GET  /api/replies?channel=C1&thread_ts=TS&cursor=...
POST /api/messages        { channel, client_msg_id, blocks, thread_ts?, file_ids? }
POST /api/messages/edit   { channel, ts, blocks }
POST /api/messages/delete { channel, ts }
POST /api/reactions       { channel, ts, emoji, op: "add" | "remove" }
POST /api/mark            { channel, ts }          -> { last_read, unread, mentions }
PUT  /api/drafts/C1       { blocks, thread_ts?, updated_at }
GET  /api/users?ids=U1,U2,U3                       -> batch lookup
GET  /api/members?channel=C1&cursor=...&limit=100
GET  /api/search?...                               -> see the Search chapter
POST /api/uploads          { name, size, type }    -> { upload_url, file_id }
POST /api/uploads/complete { file_id }
The HTTP endpoints. Every write carries an idempotency key.
json
{
  "ok": true,
  "messages": [
    { "ts": "1729412345.000200", "user": "U0G9QF9C6", "client_msg_id": "6f1c...",
      "blocks": [/* rich text */], "reply_count": 4, "latest_reply": "1729412901.000400",
      "reactions": [{ "emoji": "eyes", "count": 3, "users": ["U1", "U2", "U3"] }] }
  ],
  "has_more_before": true,
  "has_more_after": false,
  "users": [/* records for authors the client may not have */]
}

{ "ok": false, "error": "rate_limited", "retry_after": 12 }
A history page and an error. Pages say which end they reached, which is exactly what segments need.

Two details in the history response save work. has_more_before and has_more_after map directly onto a segment's reachedOldest and reachedLatest. And the response carries user records for the authors on the page, so the client does not need a second round trip before it can draw names. Errors use a stable short code the client can switch on, and rate limits return HTTP 429 with a Retry-After header that the outbox and the API client respect.

ts
type ClientFrame =
  | { type: "hello"; token: string; resume_from?: string } // last applied cursor
  | { type: "ping"; id: number }
  | { type: "typing"; channel: string; thread_ts?: string }
  | { type: "presence_sub"; ids: string[] }; // the full visible set, about once a second

type ServerControlFrame =
  | { type: "hello_ok"; cursor: string; resumed: boolean } // resumed false: cursor too old
  | { type: "pong"; id: number }
  | { type: "reconnect_soon"; after_ms: number }; // server is draining for a deploy
Socket frames the client sends. Everything the server sends was listed in the Real-time chapter.

Note the reconnect_soon frame. When the server deploys, it can tell clients to reconnect within a window instead of cutting them all at once, which spreads the load. The hello_ok frame's resumed flag is how the client learns whether cursor resume worked or it must fall back to the boot and per-channel delta.

The main components

tsx
type MessageListProps = {
  workspaceId: string;
  conversationId: string;
  threadTs?: string;            // set when rendering a thread in the side panel
  jumpTo?: string;              // ts to center and highlight
  initialAnchor?: { key: string; offset: number }; // restore on return
  dividerTs?: string;           // where the "New" line goes, frozen at open
  onAnchorChange(anchor: { key: string; offset: number }): void;
  onNeedPage(dir: "older" | "newer"): void;
  onSeen(ts: string): void;     // feeds the read tracker
};
The message list's props. It reads data from the store; props describe what to show, not the data itself.

The same list renders a channel and a thread: a thread is just a conversation of replies keyed by its parent. The composer's props mirror it: the conversation, an optional thread, and an onSend that enqueues into the outbox rather than posting.

Performance and memory: a week without a reload

Most web performance advice is about the first 3 seconds. Slack has that problem (the boot chapter) and a rarer one: the 40th hour. A client open all week receives hundreds of thousands of events, visits dozens of channels, scrolls through thousands of images, and must use about the same memory and feel just as fast on Friday as on Monday. That does not happen by accident: every structure that grows needs a cap and an eviction rule.

What growsCap and eviction ruleWhat happens if you forget
Message windowsKeep full segments for the 10 most recently viewed conversations. Others are trimmed to their newest page, then dropped after about an hour unseen. A hard cap of a few thousand messages per workspace.Every channel ever opened stays in memory with every page ever scrolled. Hundreds of MB by Friday.
Segments from jumpsDrop a jump segment when the user leaves its conversation.Islands of old history pile up and are never shown again.
User recordsKeep; they are small. Drop those not referenced by any kept message after a day.Slow growth in huge workspaces.
Images and previewsRows unmount when scrolled away, so their images can be released. Revoke every createObjectURL when its upload is sent or removed.Decoded images are large (a 1,000 x 1,000 image is about 4 MB decoded). A few hundred retained ones is over a GB.
Observers and subscriptionsEvery mount that calls observe or registers presence must undo it on unmount.Detached rows kept alive by observers; presence for users scrolled past hours ago.
TimersOne sweep timer expires all typing indicators, instead of one timer each.Thousands of timers in a busy workspace.
Background workspacesAfter about 30 minutes unseen, trim to summaries plus the newest page of each recently opened conversation. Keep the socket for badges and notifications.Five workspaces each holding a full day of history.

Rendering under a steady stream

  • Narrow subscriptions. A sidebar row selects its own summary, a message row selects its own message. A new reaction re-renders one row, not the list.
  • Stable keys. Rows are keyed by message key (or client id while pending), so a confirm or an edit updates in place instead of remounting.
  • One flush per frame. The event queue from the real-time chapter turns bursts into one update per animation frame.
  • Lazy features. Emoji data, the search UI, file viewers, settings, and huddles are separate chunks, loaded on first use or prefetched when the browser is idle. The boot bundle holds the shell, the sidebar, the list, and the basic composer.
  • Nothing heavy on the event path. IndexedDB writes, analytics, and cache trimming run in idle callbacks, never inside the socket handler.

Measuring it in production

Lab tests do not show a 40-hour session, so the client reports its own numbers: cold and warm boot time, channel switch time (click to first message painted, with performance.mark and performance.measure), INP from the web-vitals library, long animation frames (a PerformanceObserver on long-animation-frame, in Chromium), and memory by session age. performance.measureUserAgentSpecificMemory() works in Chromium on cross-origin isolated pages; the desktop app can read process memory directly. A flat memory line is the goal; a steady slope is a leak.

Living through deploys

The server ships several times a day while clients stay open for days, so the client is often a few versions old. Three rules keep that safe: the client ignores event types and fields it does not know, the server keeps old API shapes working for a while, and the client updates gently.

Updating gently means the service worker downloads the new version in the background, and the client reloads when it is safe (hidden for a few minutes, empty composer, drained outbox), restoring the URL, drafts, and anchors from IndexedDB. Slack's service worker post describes a cache per deploy, so a booted page only loads files from its own version.

Check your understanding · Exercise 13

Memory in long sessions rises by about 40 MB an hour, and a heap snapshot shows thousands of detached message row elements. Which causes are likely? Select all that apply.

Accessibility: a busy conversation for keyboard and screen reader users

Slack is a workplace tool, so for many people it is not optional: if a screen reader user cannot follow #incidents, they cannot do their job. The Screen readers lesson covers the basics. The hard parts here are a list that never stops changing, many regions to move between, and new messages that must be announced without burying the user in speech.

Landmarks: name every region

Give each region a landmark so screen reader users can jump between them: a nav labelled "Workspaces" for the rail, a nav labelled "Channels and direct messages" for the sidebar, main for the conversation (labelled with the channel name), an aside labelled "Thread" for the side panel, and role="search" around the search box. A single key (Slack uses F6) moves focus from region to region, so keyboard users do not tab through 300 sidebar rows to reach the composer.

Decision What role should the message list have?

A live log (role="log")

The log role makes the list itself a polite live region: added content is announced.

Pros

  • New messages are announced with no extra code.
  • The usual choice for a chat stream that only appends, as in the Screen readers lesson.

Cons

  • Everything added is announced, including 50 older messages loaded by scrolling up, or a whole page after a jump.
  • No control over pace in a busy channel.

A feed of articles (role="feed")

The WAI-ARIA feed pattern: each message is an article with an accessible name, the feed sets aria-busy while loading more, and keys move between articles.

Pros

  • Designed for lists that load more as you scroll.
  • Each message is a unit a screen reader can move through and read alone.

Cons

  • It is not a live region, so new messages need a separate announcement.
  • Screen reader support for feed-specific behavior varies.

A plain list

ul and li for messages.

Pros

  • Familiar, works everywhere.

Cons

  • No guidance for loading more or for keyboard movement between messages; all of it is custom anyway.

What we pick: Structure the list as a feed of articles, each labelled with author, time, and text, and announce new messages through a separate polite live region that the client controls. The structure serves reading history; the live region serves live arrivals; neither does the other's job badly.

Moving between messages

  • One tab stop for the list. Use a roving tabindex: the active message has tabindex="0", all others -1. Up and Down arrows move between messages; Home and End go to the oldest loaded and the newest. Tab moves into the active message's own controls (reactions, links, the thread button). Also support Page Down and Page Up, the keys the APG feed pattern suggests: a feed is a structure, so screen readers stay in reading mode inside it and keep the arrow keys for themselves.
  • Load as you move. When arrows reach the oldest loaded message, request the older page and keep focus on the same message after the prepend, using the same anchor logic as scrolling.
  • Focus on purpose. Switching channels puts focus in the composer, ready to type. A jump moves focus to the target message. Opening a thread moves focus into the thread panel; closing it returns focus to the message that opened it.
  • Compact rows still make sense alone. A grouped message hides its author visually, but its accessible name still includes "Maya, 10:42", so a screen reader user who lands on it knows who wrote it.
  • Reduced motion. Under prefers-reduced-motion: reduce, jumps do not smooth-scroll and the target highlight does not pulse.

Announcing new messages politely

The live region is a visually hidden element with aria-live="polite" that the client writes short summaries into: only for the focused conversation, only for messages that arrive live (never history or catch-up pages), and never the user's own. If more than 3 arrive within about 10 seconds, announce one summary, "5 new messages in incidents". Let users choose every message, mentions only, or none. Mentions and DMs elsewhere get a brief "New mention in design".

Check your understanding · Exercise 14

Which of these are good choices for the message list's accessibility? Select all that apply.

Running it in the interview

"Design Slack" is too wide to cover in 45 minutes, and the interviewer knows it. They want to see you pick the parts that make Slack different from a plain chat app, go deep on two or three of them, and show you know where the rest would go. Here is one way to spend the time.

MinutesWhat you doWhat you say or draw
0 to 5ScopeAsk about workspaces per user, channels per user, the biggest channel, how long the app stays open, web or desktop, offline. Write the numbers down.
5 to 8What makes it hardName the forces: many conversations at once, a replica always a bit behind, read state as product, long history, a process that lives for days, channels with an audience.
8 to 15Architecture and data modelDraw the shell wireframe, then the boxes: per-workspace store, sync engine, API client, outbox, persistence. Write the store's shape: summaries for all, segments for few, messages keyed by channel and ts.
15 to 22Boot and real-timeSlim boot plus warm cache. One socket per workspace. Sends over HTTP with a client id. Idempotent handlers.
22 to 30Deep dive 1: reconnectHeartbeats, backoff with jitter, resume from a cursor, fallback to boot plus delta, segments marked when a hole cannot be filled.
30 to 37Deep dive 2: unread or the listThe mark-read rule and its trade-off, seen-tracking with IntersectionObserver and focus checks, sync via events; or segments, anchors, and jump-to-message.
37 to 42BreadthLarge channels and presence subscriptions, search on the server, notification rules split between server and client, memory caps.
42 to 45Wrap upAccessibility in one minute, then what you would measure: warm boot time, switch time, INP, memory over session age.

Follow-up questions to expect

Think first

"How do you know the client did not miss a message?"

Think first

"A customer has a channel with 200,000 members. What breaks?"

Think first

"How do threads fit into your model?"

Common mistakes

  • Designing a one-to-one chat app and calling it Slack: no workspaces, no unread model, no big channels.
  • Loading every channel's history at boot, or every member's presence.
  • Picking a mark-read rule without naming its cost: marking on open clears unseen messages, marking by what was seen leaves skipped ones bold.
  • Trusting the socket to be complete, with no cursor and no catch-up story.
  • Sorting messages by arrival time.
  • Forgetting that the app runs for days: no caps, no eviction, no plan for deploys.
  • Spending 15 minutes on the composer's rich text internals, which is a separate problem.

If you remember one thing for the room: name the replica problem early, draw the two-level store, and pick reconnect or unread state as your first deep dive. Both show the senior signal interviewers look for here, which is seeing how a client can be quietly wrong and designing so it cannot be.

Summary

  • Slack is many conversations at once: keep a small summary for every conversation and full history only for the few that are open.
  • Give each workspace its own store and its own socket inside one app, with a thin account layer for what spans workspaces.
  • Boot slim and lazy, paint from an IndexedDB cache first, and treat every view as working on incomplete data.
  • Messages are keyed by conversation and server ts, sorted by ts, and stored once in a normalized store; history is a set of segments that are each known to be complete.
  • Make every event handler idempotent, resume from a cursor after a drop, fall back to a fresh boot and per-channel refetch, and never paper over a hole.
  • Send over HTTP with a client id through a persisted outbox, one in flight per conversation; the socket echo confirms it.
  • Read state is one forward-only marker per conversation, synced to other devices by events. Choose when it moves (on open, Slack's default, or by what was really on screen) from the requirements, and let users change it.
  • Presence, member lists, and typing work on what is visible, never on all members.
  • The server decides who should hear about a message; the client in front of the user decides whether to show it now.
  • A client open for a week needs caps and eviction for every growing structure, and a gentle way to update itself.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next