The whole design, in writing
Learn system design by building a team chat app like Slack step by step. An interactive guide covering channel messaging and persistence, real-time delivery over WebSockets, cross-server fan-out via pub/sub, presence and typing, message search, notifications and unread counts, and sharding by channel.
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
The big idea
What is Slack?
Team chat: people post to channels, and every member sees the message in real time, across all their devices, with full searchable history. The twist versus 1:1 messaging is fan-out to groups and durable, organized history per channel.
Persist every message to a per-channel store, deliver in real time over WebSockets, and fan a message out to all channel members — bridging servers with a pub/sub bus. Then layer presence, search and notifications on top.
What the new pieces do
- Userclient
- A person in a workspace sending messages to channels and expecting everyone else’s messages to appear instantly — across web, desktop and mobile.
Step 1 · The skeleton
Post to a channel, persist
A message is sent to a channel, not a person, and it must be saved so latecomers and other devices can read it. Where does it go, and who is it for?
A message is sent to a channel (not a person) and latecomers must read it later. What happens to it?
Fire-and-forget loses the message for anyone offline, on another device, or joining later — and there’s no scrollback. Chat’s value is the durable, organized record.
Copying a message into every recipient’s mailbox explodes storage for big channels and complicates joins/leaves and ordering. Model the channel, not N recipient copies.
The channel service looks up members and writes the message to ordered per-channel history first, so latecomers and other devices always get a complete record. Persist first, deliver second.
The Gateway hands the message to a Channel Service that looks up the channel’s members and writes the message to a per-channel Message Store. Persist first, deliver second — so history is always complete.
Why this piece earns its place
Persist-first is the right call, but notice what it costs: sending is now only as available as the Message Store. If that write is slow the send is slow, and if it fails the honest thing is to refuse the message rather than accept something we cannot keep — the client shows it unsent instead of pretending. The failure that actually bites, though, is the retry. A client sends, the write lands, the acknowledgement is lost on a flaky network, the client retries, and the same sentence appears twice in the channel. The client has no way to tell whether the first attempt succeeded, so it has to retry. The defense is that the client mints a message id before sending and the store treats it as an idempotency key: a second write carrying that id returns the first result instead of appending a second row. That id earns its keep twice over — it is also what lets a client match the optimistic bubble it already drew on screen to the message that comes back down the socket.
What the new pieces do
- Gatewaybackend
- Holds each client’s persistent connection and exposes the REST API. Receives messages, persists them, and pushes new ones down to connected clients.
- Channel Serviceservice
- Knows who belongs to each channel and routes a new message to all its members. Channels, not individuals, are the unit of delivery.
- Message Storestore
- Durable, ordered history per channel. Messages are saved before delivery so history is complete and clients can scroll back through everything.
Step 2 · Make it instant
WebSockets & sessions
Polling for new messages is laggy and wasteful. To feel live, the server must push messages the instant they arrive — but it needs to know which server holds each recipient’s connection.
Messages must appear the instant they’re sent. How does the server reach the right client live?
Polling is laggy and wasteful — most polls return nothing, and "live" still feels a second behind. To feel instant the server must push, not wait to be asked.
Each client holds a long-lived socket to a gateway; a registry records which server holds each user’s connection, so "deliver to user U" becomes "push on this socket on this server" instantly.
Wrong channel and far too slow for live chat, and unboundedly noisy. Out-of-band notifications are for offline users (step 6), not the real-time path.
Clients hold a persistent WebSocket to a gateway. A Session Registry records which server each online user is connected to, so the system can route a message straight to the right connection and push it down immediately.
Why this piece earns its place
The Session Registry is a directory of a fact that goes wrong quietly. A socket does not always close politely — a laptop lid, a NAT table expiring, a phone switching networks — so the registry keeps claiming a user is on a server that no longer holds them, and pushes go into a dead connection. The knob here is the heartbeat interval and the lifetime of a registry entry. Set them long and you push into sockets nobody is on, and the user sees nothing new until they refresh. Set them short and you pay in churn: every connection rewriting its entry constantly, and users evicted and reconnecting over a momentary blip, which is worse than the staleness you were fixing. Two details fall out. An entry is per connection, not per person — the same user on desktop and phone is two rows, so “deliver to user U” resolves to a set of sockets across servers. And because messages are stored before they are pushed, a stale entry costs a delayed message, never a lost one, which is what makes it safe to treat this directory as a cache at all.
What the new pieces do
- Session Registryservice
- Maps each online user to the server holding their WebSocket, so the system knows exactly where to push a message for a given recipient.
Step 3 · Members are everywhere
Cross-server fan-out
A channel’s members are spread across many gateway servers. The server that received a message can only push to its own connected clients — everyone else would miss it.
A channel’s members are spread across many gateway servers. The receiving server only knows its own clients. How does everyone get the message?
All-to-all gateway chatter is O(servers²) and tightly couples them — adding a server means rewiring everyone. You want one publish, not a mesh of point-to-point calls.
The sending gateway publishes once; every gateway subscribed to that channel pushes to its local sockets. One publish reaches members on any server, and gateways become interchangeable subscribers you can add freely.
Pinning a whole channel to one server creates hotspots (a huge channel overwhelms it) and breaks when users are in many channels on different servers. Bridge servers with a bus instead.
Publish each accepted message to a Message Bus (pub/sub). Every gateway subscribes to the channels its connected users care about and pushes the message to those local sockets. One publish reaches members on any server.
Why this piece earns its place
The subscribe side is where this strains. A gateway subscribes to the channels its connected users care about, but a busy gateway holds thousands of users who between them sit in most of the workspace’s channels — so its channel set creeps toward all of them and precise routing quietly becomes broadcast-and-filter. That is survivable if you plan for it: deduplicate subscriptions so a channel is subscribed once per server rather than once per socket, and accept that past some density the bus is simply telling every gateway everything. The other thing to design for now is redelivery. A bus that guarantees anything usually guarantees at-least-once, so a hiccup or a consumer rebalance will replay, and the same message will be pushed down a socket twice. So clients dedupe on message id and treat the socket as a stream of hints about a history they can always re-fetch — never as the record itself. Build it that way and the bus being lossy, late or repetitive is an annoyance rather than a correctness problem.
What the new pieces do
- Message Busbus
- Relays each message between gateway servers, so a member connected to a different server still receives it. The glue for cross-server delivery.
Back of the envelope
- 1 publish ⇒ all subscribed gateways
- O(servers), not O(servers²) chatter
- gateways = interchangeable subscribers
- add servers without rewiring delivery
- decouples "message happened" from "who’s where"
- the bus bridges connection topology
Step 4 · Who’s around?
Presence & typing
The green dots and “Bob is typing…” are core to chat, but they’re extremely high-frequency and disposable — running them through the durable message path would swamp it.
Green dots and "Bob is typing…" fire constantly and are disposable. How do you handle them?
Persisting a flood of high-frequency typing events would swamp the durable store with data nobody reads back. Presence is throwaway — it shouldn’t touch message storage at all.
Mixing high-frequency disposable signals into the durable pipeline risks the thing that matters (messages) during a presence storm. Keep them on separate paths.
Track online/away/typing in fast ephemeral storage and broadcast over the same sockets, but never store it. If everyone disconnects, presence simply vanishes — and a typing storm can’t risk the message store.
Handle Presence on a separate channel: track online/away/typing in fast, ephemeral storage and broadcast updates over the same WebSockets, but never persist them. If everyone disconnects, presence simply disappears.
Why this piece earns its place
Separating the path is right; the volume on that path still has to be managed, because typing is the one event a user generates continuously. Broadcast it naively and one person typing in a large channel produces more socket traffic than an actual conversation — and it scales with membership, not with how much anyone is saying. So throttle at the source: the client emits at most one typing signal every few seconds while keys are going down, and that signal carries its own short expiry so the indicator clears itself. That detail matters more than it sounds. If clearing depends on a “stopped typing” event, every dropped one leaves somebody typing forever, and the feature starts lying. Then narrow the audience — push presence only to sockets with that channel actually on screen, not to every member of it. Going too far the other way has its own cost: throttle hard enough and the indicator shows up after the message does, which is worse than not having it, since the whole point is to tell you to wait a moment.
What the new pieces do
- Presenceservice
- Tracks who’s active and who’s typing, broadcasting these high-frequency, throwaway signals separately from durable messages.
Back of the envelope
- typing fires every keystroke
- orders of magnitude more events than messages
- in-memory, broadcast, never stored
- a presence storm can’t touch the message store
- everyone disconnects ⇒ presence gone
- disposable by design
Step 5 · Find old messages
Search
Teams accumulate millions of messages, and the value of chat is partly the searchable record. Scanning the message store for a keyword across years of history is far too slow.
Teams accumulate millions of messages and need to find old ones by keyword. How?
Scanning years of history per query is far too slow. You need a structure built for retrieval, not a linear scan of the raw store.
Index messages as they’re written so queries hit the index, not the raw store — and scope results to channels the user can access. Permissions must be part of the query, not an afterthought.
Clients hold only a sliver of history and can’t search what they never received or channels they just joined. Search must run server-side over the full indexed record.
Index messages into a Search Index (inverted index) as they’re written, scoped by channel and permissions. Queries hit the index, not the raw store, and only return messages the user is allowed to see.
Why this piece earns its place
Indexing at write time means every message is now written to two systems with different failure modes, and they will drift. A failed index write is invisible — the message is in the channel, it simply cannot be found, and a search returning too few results looks exactly like a search with no matches. So I would not index from a best-effort call on the request path. Drive the Search Index off the durable record itself, as a consumer reading the message history with a position it can rewind, so an index outage or a bad mapping change is repaired by replaying from an offset rather than by working out what went missing. The other temptation worth resisting is folding permissions into the index — storing each message’s allowed readers beside it so a query is one cheap lookup. It reads faster and it is a maintenance trap: every join, every leave, every channel switched to private rewrites a slice of the index, and the moment that rewrite lags, someone is searching a room they were removed from.
What the new pieces do
- Search Indexindex
- An inverted index over message history so users can find old messages by keyword, scoped to channels they can access.
Step 6 · Pull them back
Notifications & unread
People aren’t always watching. Mentions and DMs need to reach them via push, and every client must show accurate unread counts and badges — consistently across devices.
Every device must show accurate unread counts and badges, consistently. How do you compute "unread"?
A raw counter drifts across devices and is hard to keep consistent (which device decremented?). It also can’t say which messages are unread, only how many — fragilely.
Re-scanning a channel’s history to count unreads on every app open is wasteful at scale. There’s a cheap derivation if you track one marker instead.
Unread is just "messages after my last-read marker" — cheap to compute, and syncing that one cursor across devices keeps every client in agreement automatically.
A Notifications service tracks each user’s last-read position per channel to compute unread counts, and sends push notifications for mentions/DMs to offline users. Read state syncs across a user’s devices via the gateway.
Why this piece earns its place
A cursor makes unread a derived quantity, which is the right model, but it moves the work to read time and it fans out: someone in hundreds of channels opens the app and every badge has to be produced at once, while they watch. That stays cheap only if counting is arithmetic rather than scanning — each channel’s messages carry a monotonic position, so unread is the distance between the channel’s latest position and my cursor. It is also why a badge on a very busy channel can be capped or approximate while mention counts stay exact: nobody audits whether a channel says nine hundred or nine hundred and change, and everybody notices a mention badge that is wrong. The race worth naming out loud is between the push and the read. Someone reads on their laptop and their phone buzzes a moment later, because the notification was decided before the cursor moved. Hold the push briefly and let an active session cancel it — a late notification is forgivable, a notification for something you just read is the one people turn off.
What the new pieces do
- Notificationsservice
- Computes unread counts and sends push notifications for mentions and DMs to users who are offline or away.
Back of the envelope
- unread = messages after last-read
- a single per-user, per-channel cursor
- cheap to compute
- no re-scan, no drifting counter
- cursor syncs across devices
- every client agrees
Step 7 · Scale & big channels
The sharp edges
A huge company workspace, and an announcement channel with tens of thousands of members, both strain fan-out and connection counts. Threads add another dimension to ordering and delivery.
Shard by channel (and workspace) so a channel’s membership and messages live together, and run many stateless gateways for the connection load. For giant channels, fan out lazily (members pull on read) rather than pushing to everyone at once; model threads as messages linked to a parent.
Why this piece earns its place
Sharding by channel fixes the general case and does nothing for the specific one that hurts: a giant channel is a single shard’s hot key, and you cannot split it without splitting the thing that makes it a channel. Its recent history wants read replicas and its writes want a rate limit, because the announcement everyone reacts to is a write storm as well as a read one. The pull model has an edge worth saying too — if a huge channel stops pushing entirely, members only discover the announcement when they next look, so you still send something, just something cheap: a coalesced marker saying this channel changed, rather than the message body per member. And the operational failure at this size is the deploy, not the outage. Gateways hold every connection in the product, so restarting them together drops every socket at once and the whole workspace reconnects, re-authenticates and re-subscribes in the same few seconds. Stagger the restarts and put randomized backoff in the client, or the recovery is a bigger event than whatever it was recovering from.
You did it
You just designed Slack.
Everything you assembled, in order
- Channel-centric messaging: persist to a per-channel store, then deliver.
- WebSockets plus a session registry give instant, routed push delivery.
- A pub/sub bus fans each message out to members across all servers.
- Presence and typing run on a separate, ephemeral, never-persisted path.
- A write-time search index (with permissions) makes history findable.
- Per-user last-read cursors drive unread counts and offline notifications.
- Shard by channel and pull-fan-out for huge channels to scale.