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.
| Question | Answer we assume | What 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).
| Force | What it rules out | Where we handle it |
|---|---|---|
| Many conversations at once | Loading history for every channel at start | Data model (two levels), Boot and loading |
| Always a little behind | Trusting the socket stream to be complete | Real-time events, Reconnect and outbox |
| Read state is the product | Choosing a mark-read rule without naming its cost | Unread state and mentions |
| Long history, random access | One list that only grows upward | The message list |
| Lives for days | Unbounded caches and listeners | Performance and memory |
| Channels with an audience | Full member lists and presence for all | Large 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?
Carry over: client ids for sends, an outbox, ordering by a server id, dedupe on merge, reconnect with catch-up, a virtual list anchored to the bottom. These are the same here, and we link to them rather than teach them again.
Break: per-message read receipts (in a 5,000-member channel nobody wants "seen by 3,214"), loading every conversation's recent messages at start, presence for every contact, and one socket for one account. Slack replaces receipts with a per-user read marker, loads summaries instead of histories, limits presence to what is visible, and runs one connection per workspace.
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.
| Region | What it shows | Data it needs | How often it changes |
|---|---|---|---|
| Workspace rail | One icon per workspace, with a dot or number when something is waiting there | One unread flag and one mention total per workspace | Rarely; only when totals cross zero |
| Sidebar | Sections (Starred, custom sections, Channels, Direct messages), one row per conversation | A summary per conversation: name, kind, muted, unread, mention count, presence for DMs | Often: every new message anywhere can touch one row |
| Header | Channel name, topic, member count, huddle status | The channel record | Rarely |
| Message pane and composer | The message list for one channel and the box to write in | A window of messages, the users who wrote them, the draft | Constantly while the channel is busy |
| Side panel | A thread, a profile, channel details, or search results | The thread's replies, or the profile | Only 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?
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.
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.
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[];
};
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
| Data | Lives in | Why there |
|---|---|---|
| Workspace, channel, and user records | Store in memory, mirrored to IndexedDB | Needed 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 boot | The sidebar needs them before anything else |
| Message windows (segments) | Store for recently opened channels; the newest page of each mirrored to IndexedDB | Open channels render from memory; the cache gives instant paint and offline reading |
| Drafts | Store, IndexedDB, and the server | Must survive reloads and follow the user to other devices |
| Pending sends (outbox) | Store and IndexedDB | A message typed offline must survive closing the app |
| Current workspace, channel, thread, jump target | URL | Shareable, back button, reload |
| Scroll anchors, collapsed sections, pane widths | Memory and IndexedDB (per device) | Personal view state, not shared |
| Typing indicators and presence | Memory only, with expiry | Short-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?
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.
hello_ok says whether the old cursor could resume.The boot, step by step
- 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.
- Paint from cache. Read summaries, users, and the open channel's newest page from IndexedDB (tens of milliseconds) and render.
- 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. - 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.
- Go live. The socket's first frame confirms the event cursor, and events keep the store current.
- 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?
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
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 };
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).
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.
Catching up: filling the hole
Decision After the socket is back, how does the client learn what it missed?
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.
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
#opsgo out in parallel, the second can get an earliertsand 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
5xxanswers back off, a429waits for itsRetry-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).
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?
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.
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);
}
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.
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.
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?
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.
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:
- Put the target in the URL (
?msg=1698765432.001200) so the back button returns to where the user was before the jump. - Look for a segment of C that already contains the target
ts. If one exists, render it and scroll to the row. - If not, fetch a page around the target: about 25 messages older and 25 newer, in one request (or two requests with
latestandoldestset to the target). Store it as a new segment that reaches neither the oldest nor the latest. - 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.
- 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.
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.@channeland@herein 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.
- Progress.
fetchdoes not report upload progress in all browsers, so useXMLHttpRequestand itsupload.onprogressevent for the progress bar, or upload in chunks withfetchand 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.
- 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.
Search: on the server, with filters, landing in history
Search in Slack has two faces. The quick switcher (Ctrl+K or Cmd+K) finds a channel or a person to jump to, and it must answer in a few tens of milliseconds. Full search finds messages and files across years, with filters. They need different designs.
The quick switcher searches local data first: every joined conversation's summary is in the store, so matching a few hundred names takes well under a millisecond and results show as the user types. In parallel, a debounced server query adds channels the user has not joined and people the client has not loaded; Slack's post on its edge cache describes the quick switcher asking that cache for exactly this. Server results are appended below, never reordering what is under the cursor.
Why full search must run on the server
- The client holds almost nothing. A few thousand recent messages out of millions. A local search would miss nearly everything.
- Permissions change. Results must respect which private channels and DMs the user is in right now, and data retention rules set by the workspace. The server enforces them in one place.
- Ranking needs signals the client lacks: how often the user talks to the author, which channels they read, how recent the message is.
Filters as structure, not just text
Slack's search understands modifiers such as in:#design, from:@maya, has:link, is:thread, before:2026-01-01, after:, and during:march. The Gmail guide covers operator UX in depth; the Slack twist is that most filter values are entities (a channel, a person) that may not be in the store.
So the client parses the query as the user types: when a token starts with in: or from:, it opens an autocomplete for channels or people (the same local-then-server search as the quick switcher), and a chosen value becomes a chip that holds an id, not a name. The request sends structured filters plus free text, so from: means one user id, not every user whose name contains "maya".
// GET /api/search?q=...&cursor=...
{
"text": "rollback plan",
"filters": { "in": ["C024BE91L"], "from": ["U0G9QF9C6"], "has": ["link"], "before": "2026-01-01" },
"sort": "relevance",
"limit": 20
}
// response
{
"results": [{
"channel": "C024BE91L", "ts": "1731002211.004900", "thread_ts": null,
"user": "U0G9QF9C6", "blocks": [ /* the message, same format as history */ ],
"highlights": [{ "start": 14, "end": 22 }, { "start": 23, "end": 27 }]
}],
"total": 37,
"next_cursor": "dXNlcjpVMEc5V0ZYTlo="
}
The results render with the same message component as the channel, in a compact mode, with highlight ranges wrapped in mark elements built by the renderer (never by inserting server strings as HTML). Each result carries its channel and ts, which is everything the jump-to-message flow from the message list chapter needs. Clicking a result opens the channel at that message, centered and highlighted, with the search panel kept open in the side panel so the user can try the next result.
The details that make it feel good
- Full search runs on Enter, not on every key (the quick switcher runs on every key). Suggestions while typing come from a cheap endpoint, debounced, with the previous request aborted (
AbortController). - Results for a query are cached for the session, so going back from a result to the list is instant and keeps its scroll position.
- Tabs for Messages, Files, Channels, and People show counts from one response, so switching tabs does not refetch.
- Offline, the search box says that search needs a connection, and offers to search the messages already on this device, clearly labeled as partial.
Check your understanding · Exercise 11
Which of these belong on the client in Slack's search design? 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?
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.
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.visibilityStateis"visible", anddocument.hasFocus()is true, skip the banner. A visible but unfocused window still notifies. - One banner per conversation. Create notifications with
tagset 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_markedevent 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.
| Part | Responsibility | Why it is separate |
|---|---|---|
| App shell and router | Layout, the URL (workspace, channel, thread, jump target), lazy loading of big features | The URL is the one source of truth for "where am I"; nothing else writes it |
| Account layer | Signed-in user, workspace list, total badge, notification leader, preferences shared across workspaces | Holds 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 state | The 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 cursor | The only writer of server events into the store, so ordering and dedupe live in one place |
| API client | HTTP calls with auth, retries, idempotency keys, AbortController, rate-limit handling | One place to change auth or add tracing |
| Outbox sender | Drains pending sends and uploads in order per conversation | Sends must survive reloads and offline; the composer just enqueues |
| Persistence | Writes store changes to IndexedDB in idle-time batches; reads them at boot | Keeps disk work off the event path |
| Read tracker | Watches rows, moves the last read marker, debounces mark requests | Read rules are subtle; keeping them out of the list component keeps both simple |
| Presence manager | Reference counts visible users, sends the subscription set once a second | Turns hundreds of mounts into one frame a second |
| Notification controller | Applies local rules to flagged events, shows and closes notifications, sets badges | Needs focus and route state that the store should not know about |
| Service worker | Caches app files per deploy for warm boot; receives Web Push | Runs when no page is open |
| Views | Sidebar, message pane, composer, side panel, search, quick switcher | Read 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.
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?
Because the list is not the only one who cares about that page. The store must merge it into the right segment, dedupe it against live events, persist part of it, and maybe merge two segments. If the list fetched and held the page in component state, that logic would be copied in every view that shows messages (channel, thread, search context), and they would drift apart.
Instead, the list says "I need older messages for this segment", the store's action asks the API client, and the result enters the store like any other data. The list re-renders from the store. One path in, many views out.
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.
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 }
{
"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 }
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.
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
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
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 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 grows | Cap and eviction rule | What happens if you forget |
|---|---|---|
| Message windows | Keep 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 jumps | Drop a jump segment when the user leaves its conversation. | Islands of old history pile up and are never shown again. |
| User records | Keep; they are small. Drop those not referenced by any kept message after a day. | Slow growth in huge workspaces. |
| Images and previews | Rows 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 subscriptions | Every 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. |
| Timers | One sweep timer expires all typing indicators, instead of one timer each. | Thousands of timers in a busy workspace. |
| Background workspaces | After 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?
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 hastabindex="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.
| Minutes | What you do | What you say or draw |
|---|---|---|
| 0 to 5 | Scope | Ask 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 8 | What makes it hard | Name 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 15 | Architecture and data model | Draw 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 22 | Boot and real-time | Slim boot plus warm cache. One socket per workspace. Sends over HTTP with a client id. Idempotent handlers. |
| 22 to 30 | Deep dive 1: reconnect | Heartbeats, backoff with jitter, resume from a cursor, fallback to boot plus delta, segments marked when a hole cannot be filled. |
| 30 to 37 | Deep dive 2: unread or the list | The 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 42 | Breadth | Large channels and presence subscriptions, search on the server, notification rules split between server and client, memory caps. |
| 42 to 45 | Wrap up | Accessibility 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?"
Every event carries a cursor, and the client stores the last one it applied. On reconnect it sends that cursor; the server either replays from it (resumed: true) or says it is too old. In the second case the client re-runs the slim boot, compares each conversation's latest with what it holds, refetches the newest page of open conversations, and marks segments that might have holes as not reaching the latest.
The list never shows a seam it cannot vouch for: a segment is a run of history known to be complete. And because handlers are idempotent, overlaps between replay and live events are harmless.
Think first
"A customer has a channel with 200,000 members. What breaks?"
Anything that touches all members: the member list (page it, search on the server), presence (subscribe only to what is visible, as Slack's own blog describes), "who reacted" lists (first few names and a count), typing (cap names, let the server drop typing for huge channels), join and leave messages (hide them). @channel gets a warning with the real count and can be restricted by admins.
Messages themselves are fine: the client sees the same message rate as in any busy channel. The fan-out cost is on the server.
Think first
"How do threads fit into your model?"
A reply is a message in the same conversation with thread_ts set to its parent's ts. The parent carries reply_count, latest_reply, and a few reply authors, which is enough to draw "4 replies, last reply 2 hours ago" in the channel without loading the thread.
Opening the thread loads replies into a segment keyed by the parent, rendered by the same list component in the side panel. Each followed thread has its own read marker, and replies count in a Threads view rather than in the channel badge, unless they were also sent to the channel.
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 byts, 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.
References
- Slack Engineering: Real-time Messaging Gateway and channel servers, the client's WebSocket setup, sends through the web API, and presence only for visible users.
- Slack Engineering: When a rewrite isn't: rebuilding Slack on the desktop One Redux store per workspace inside one process, and a lazily loaded, incomplete data model.
- Slack Engineering: Service Workers at Slack Warm and cold boots, a persisted store in IndexedDB, and asset caches per deploy.
- Slack Engineering: Flannel, an application-level edge cache Why the full boot snapshot stopped scaling, and lazy loading from an edge cache.
- Slack Engineering: Making Slack faster by being lazy History fetched only for the open channel, with unread and mention counts from a separate call.
- Slack Help Center: Manage your Mark as Read preference The three mark-as-read rules Slack lets each user choose.
- Slack Help Center: Search in Slack Search modifiers such as in:, from:, has:, is:thread, before:, after:, and during:.
- Slack Developer Docs: rtm.start to stop The retirement of the full-snapshot connect call in favor of rtm.connect.
- MDN: Intersection Observer API The API behind seen-tracking and loading older pages.
- MDN: Notifications API Permission, tags, and closing notifications.
- MDN: Badging API Setting a number on an installed web app's icon.
- WAI-ARIA Authoring Practices: Feed pattern Articles in a scrolling feed that loads more content.
- MDN: IndexedDB API The local store behind warm boot, drafts, and the outbox.
Concepts behind it
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Web push notificationsMessages your server sends through the browser's push service, which wake your service worker to show a notification even when your site isn't open: powerful for re-engagement, and easy to ruin with a bad permission prompt.
- Presence and live cursorsShowing who else is in a shared document and where they are pointing, in near real time: a stream of small, short-lived updates that must stay cheap, smooth, and honest about who has left.