Engineering Atlas › System Design Interviews

Design a team collaboration chat tool

18 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope: huddles, file storage internals, search ranking, enterprise key management and Slack Connect federation.

Back-of-envelope

These are assumed round numbers, not Slack's published figures.

API

POST /v1/channels/{channel_id}/messages
  { "client_msg_id": "uuid-v4", "text": "ship it", "thread_root_seq": null }
  -> 200 { "channel_id": "C42", "seq": 18231, "message_id": "M9f..", "ts": 1760500000123 }

GET /v1/channels/{channel_id}/messages?after_seq=18200&limit=50
  -> 200 { "messages": [ { "seq": 18201, "author": "U7", "text": "...", "ts": ... } ], "has_more": false }

POST /v1/sync
  { "cursors": { "C42": 18200, "C77": 990 } }
  -> 200 { "channels": { "C42": { "head_seq": 18231, "unread": 31, "mentions": 1 },
                          "C77": { "head_seq": 990,   "unread": 0,  "mentions": 0 } } }

POST /v1/channels/{channel_id}/read   { "seq": 18231 }  -> 204

WS /v1/connect  (after the hello frame, server pushes events)
  <- { "type": "message", "channel_id": "C42", "seq": 18231, "message": { ... } }
  <- { "type": "message_changed", "channel_id": "C42", "seq": 18100, "edit_version": 2 }
  -> { "type": "ping", "id": 17 }

client_msg_id makes a send idempotent. If a phone retries after a timeout, the server returns the original seq and does not create a duplicate.

Data model

messages is partitioned by channel_id, with primary key (channel_id, seq). Columns: message_id (globally unique, time-sortable), client_msg_id, author_id, ts, text, thread_root_seq, edit_version, deleted. It has a unique secondary index on (channel_id, client_msg_id) so retried sends are deduplicated. This is the right partition key because history reads are always "one channel, a range of seq". A clustered primary key turns them into one sequential range scan on one shard.

channels is partitioned by channel_id and lives on the same shard as its messages. Columns: workspace_id, name, kind (public, private, dm), head_seq, member_count. head_seq is the per-channel counter. Keeping it on the same shard as the message rows lets one local transaction both increment it and insert the message.

memberships holds (user_id, channel_id) -> last_read_seq, notify_prefs. It is stored twice:

Both copies are written through an outbox, so they agree eventually.

Sharding: relational shards with Vitess-style routing on channel_id give range scans and transactions for the counter (Slack moved to Vitess between 2017 and 2020, partly so it could shard message data by channel instead of keeping a whole workspace on one shard, and by late 2020 Vitess served 99% of its MySQL traffic at 2.3M QPS peak, Slack). A wide-column store such as Cassandra or ScyllaDB also works with partition key (channel_id, time_bucket), but then seq must come from a separate counter service.

High-level design

flowchart LR
  C["Client (desktop/mobile)"] -->|HTTPS send / history| API["API tier"]
  C <-->|WebSocket| GW["Gateway servers"]
  API --> MS["Message service"]
  MS -->|"txn: head_seq+1, insert"| DB[("Channel shards")]
  MS -->|publish after commit| CS["Channel servers (hash by channel)"]
  CS -->|fan-out to subscribed gateways| GW
  MS --> OB["Outbox -> Kafka"]
  OB --> PN["Push notifications"]
  OB --> SR["Search indexer"]
  OB --> UR["Unread counters"]
  API --> HC["History cache (latest page)"]
  HC --> DB

Write path.

  1. The client sends POST /messages with a client_msg_id.
  2. The API tier checks membership against a cached membership set.
  3. The message service runs one transaction on the channel's shard. It increments channels.head_seq, inserts the row with that seq, and writes an outbox row.
  4. On commit it returns seq to the sender. The sender's client changes the message from "pending" to "sent" and moves it to its final position.
  5. The message service publishes the event to the channel server that owns channel_id, chosen by consistent hashing.
  6. The channel server looks up which gateways have members of this channel connected and sends one frame to each.
  7. Each gateway writes the frame to the matching local sockets.
  8. Separately, the outbox relay feeds Kafka. Kafka drives push notifications to offline users, search indexing, link unfurls and unread counters.

Read path.

Deep dives

1. Ordering: who assigns the sequence number

Three options were considered.

2. Real-time fan-out

The naive design keeps a user -> socket map and loops over channel members. For a 50k-member channel that means 50k lookups, most of them for offline users.

Instead, the routing keys on channels. When a client connects, its gateway subscribes to every channel the user belongs to on the owning channel servers. A channel server then holds channel_id -> set(gateway_id). One message becomes one frame per gateway, at most a few hundred. Each gateway keeps its own local channel_id -> sockets index. This is how Slack describes its own system: channels map to in-memory channel servers by consistent hashing, about 16 million channels per host at peak, gateways in several regions subscribe to them asynchronously after the client connects, and a message crosses the world in about 500 ms (Slack).

The follow-on problem is subscription churn: a user in 300 channels creates 300 subscriptions per connect, so gateways batch them per channel server and stay subscribed while any local socket needs the channel. For announcement channels, also drop typing indicators and coalesce reactions into one update every 1 to 2 seconds.

3. Client sync and gap detection

For each channel, the client stores cursor, the highest seq it holds contiguously. When a push event arrives:

On reconnect the client sends all of its cursors to /sync. A user with 300 channels sends about 300 × 16 bytes ≈ 5 KB.

The server answers with heads only. A client whose cursor equals the head has nothing to fetch. One that is behind shows an unread badge and fetches when the user opens the channel. If it is more than a page behind, it jumps to the newest page and fills older messages as the user scrolls, so it never replays thousands of messages.

Edits bump edit_version on the original row instead of taking a new seq, because they do not change the order of messages.

Down the stack

Layer 1The contract

  • Send. Once the sender has the seq, the message is committed on the shard primary and on at least one replica (semi-synchronous replication). Every reader of the channel will see it at that position. A retry with the same client_msg_id returns the same seq.
  • Push. Delivery is at-most-once per socket, with p99 under 1 s while connected. Nothing more is promised.
  • History. A fetch of after_seq=k returns a contiguous run starting at k+1. A client that combines push with fetch ends up with every message in seq order and no gaps. You can test this by killing sockets at random and comparing what each client holds against the log.

Layer 2The mechanism

In MySQL or InnoDB the send runs:

UPDATE channels SET head_seq = head_seq + 1 WHERE id = ?
SELECT head_seq
INSERT INTO messages ...
INSERT INTO outbox ...
COMMIT

The UPDATE takes an exclusive record lock that later writers queue behind. The unique index on (channel_id, client_msg_id) makes a retried insert fail with a duplicate key. The service catches that and reads back the existing seq.

The commit writes redo log and binlog entries. With semi-sync, the primary waits until at least one replica has written the events to its relay log and flushed it, not applied them. If no replica answers before the timeout, MySQL silently reverts to asynchronous replication until one catches up (MySQL docs), so alert on that state or the Layer 1 promise quietly weakens.

Gateways speak WebSocket. Each event is one text frame: a 2 to 14 byte header plus a JSON payload; server-to-client frames are unmasked, so they use at most 10 bytes of header (RFC 6455, section 5.2). Ping and pong frames detect dead peers.

Layer 3The machine

  • Commit cost. A commit forces the InnoDB redo log to disk with fsync, which takes about 50 to 200 µs on a datacenter NVMe drive. Group commit lets dozens of transactions share one flush, so a shard sustains thousands of commits per second even though each one waits for durability. The semi-sync acknowledgement adds one round trip within the region, about 0.3 to 1 ms.
  • Gateway memory. Each socket costs kernel buffers (a few KB idle, more with traffic), TLS session state (roughly 10 to 40 KB depending on the library) and the application's own subscription state. At 50k sockets that is a few GB of RAM. One epoll_wait loop per core handles the readiness events.
  • Frame size. A 1 KB message fits in one TCP segment (MSS about 1,448 bytes), so with TCP_NODELAY set it costs one packet and one round trip to reach the client.

Why it's built this way

  1. Ordering has to be exact per channel. A dense counter is the cheapest way to get it, because one row lock serialises writers.
  2. A row lock only works on one machine, so the counter and the messages share a shard keyed by channel.
  3. Pushing to millions of sockets cannot be made reliable cheaply. Pushes are therefore best-effort, and the durable log with cursors carries correctness.
  4. Cursors make catch-up cost proportional to what was missed, not to how much history exists. That keeps a reconnect storm affordable.
  5. Per-user fan-out does not scale with channel size, but the number of gateway hosts is bounded. Routing on channel to gateway caps the fan-out work.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Counter row in the channel shardGap-free, matches commit order, simpleCaps writes per channel at a few thousand per secondChat, comment threads, per-entity logs
Snowflake IDs as the orderNo coordination, high write rateSkew breaks order; no gap detectionFeeds where approximate order is fine
Relational shards (Vitess)Transactions, secondary indexes, mature toolingResharding is an operation you must runYou need the counter and dedupe in one transaction
Wide-column store (Cassandra/Scylla)Large scale, cheap writes, multi-DCNo multi-row transactions; seq must come from elsewhereVery large message volume with simpler semantics
Fan-out per gatewayBounded work per messageSubscription state on channel serversLarge channels, many members online
Fan-out per user (inbox per user)Simple reads per userWrites multiply by channel sizeSmall group chats and DMs only

Leadership lens

Check yourself

  1. Why assign seq from a counter row in the channel's shard instead of using Snowflake IDs?

    Show answer

    Snowflake IDs need no coordination, but clock skew between hosts can produce IDs that do not match commit order, and sparse IDs stop clients from telling a gap from a missed message. A counter incremented in the same transaction as the insert gives dense, gap-free numbering in commit order, at the cost of capping writes per channel at a few thousand per second.

  2. A gateway drops a push frame. How does the client end up with every message anyway?

    Show answer

    Push is best-effort and the log is the truth. When a later event arrives with seq > cursor + 1, the client buffers it, records the missing range and fetches after_seq=cursor from the history API, so combining push with fetch yields every message in order.

  3. Why is a reconnect storm, not message sends, the load you plan capacity for, and how is it kept affordable?

    Show answer

    A deploy or network blip can trigger millions of /sync calls at once. Clients reconnect with exponential backoff and full jitter, sync returns only per-channel heads from a cache (about 5 KB for 300 channels), and gateways drain about 1% of sockets per second during deploys.

  4. Why fan out to gateways instead of looping over channel members, and roughly how many gateways are there?

    Show answer

    Looping over a 50k-member channel means 50k lookups, mostly for offline users, while a channel server holding channel_id -> set(gateway_id) sends one frame per gateway, at most a few hundred. With about 12M sockets at peak and 50k per host, that is around 240 hosts, provisioned as about 400.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →