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.
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.
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.
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.
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.
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.
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.
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.