Scale Interview
System DesignMedium

Design Slack

Team Chat & Channels System Design

Overview

Slack is team chat, not one-to-one messaging. People join a workspace (their company's private Slack), and inside it they talk in channels like #engineering or #general. The defining shape is one-to-many: you post one message to a channel and everyone in it sees it in real time, in the same order, within a fraction of a second. Alongside that live feed, the product tracks who is online (presence), how many messages you have not yet read in each channel (unread counts), full scrollback history, and threaded replies.

It is labeled "Medium" because the core mechanics are well-understood, but Slack twists them in three teachable ways. First, delivery is channel fan-out: one post can wake thousands of connected members, so you cannot naively loop over the membership list on every message. Second, presence and unread state at org scale are deceptively expensive — a 50,000-person workspace where everyone watches everyone's green dot, or where unread is recomputed by scanning messages, will melt. Third, history and search must stay fast as a workspace accumulates years of messages across thousands of channels.

A strong answer holds persistent WebSocket connections through a gateway layer, fans a posted message out only to connected channel members via a connection registry, computes unread from a per-channel sequence and a cheap read cursor instead of scanning messages, and stores messages partitioned by channel for fast scrollback plus a separate index for workspace search.

This is distinct from a 1:1 chat app like WhatsApp: there the unit is a private conversation with one recipient and fan-out only appears in group chats. Here the channel is the unit, one-to-many is the default, and the interesting problems — channel fan-out, unread-per-channel, workspace-wide search, org-scale presence — all fall out of the team-chat model.

Out of scope (say so): voice/video huddles, file upload/transcoding internals (we note where a file reference fits), the client rich-text editor, and access-control/SSO specifics beyond "messages are isolated per workspace."

Functional requirements

  • Post and receive channel messages. A user posts a message to a channel; every member of that channel receives it in real time, in a single consistent order.
  • Workspaces and channels. A workspace contains many channels; users belong to a workspace and join a subset of its channels. Messages never cross workspace boundaries.
  • Presence. Show whether each member is online or away — the green/grey dot in the member list and sidebar.
  • Unread state. For every channel a user is in, show how many unread messages there are and jump them to the first unread; mark a channel read when they view it.
  • Message history. Scroll back through a channel's past messages, including on a fresh device — paginated, in order.
  • Threads. Reply in a thread hanging off a specific message, so side conversations don't flood the main channel.
  • Search. Find messages across the whole workspace by keyword, filtered by channel or author.

Out of scope (state it): huddles/calls, deep file handling, message editing/deletion mechanics (we mention where they fit), and the admin/billing surface of the workspace itself.

Non-functional requirements

  • Low-latency real-time delivery. A posted message should reach every online channel member in well under a second — target < 500 ms p99. This drives the persistent-connection + fan-out design.
  • Per-channel ordering. Everyone in a channel must see its messages in the same order. Order across unrelated channels does not matter.
  • Durability. Once the server accepts a message, it must survive a crash and appear in history. Presence and typing indicators are ephemeral and need no durability.
  • Read-your-own-writes for unread. When you open a channel and read it, the unread badge must clear and stay cleared across your devices — unread state must be consistent enough that the badge isn't lying to you.
  • Horizontal scale. Connections, message writes, fan-out, and search all grow with users and workspaces, so every layer scales by adding machines.
  • Workspace isolation. A workspace's messages, search index, and presence are logically separated — a query in one org never touches another's data.

Estimations

State assumptions; the goal is to justify the connection layer, the fan-out design, and the storage/search split — not to be exact.

Rounded assumptions

  • Workspaces: ~1M, of wildly varying size. Model a large one at 50,000 users; most are far smaller.
  • Total users: ~50M, with ~10M online at once at peak (business hours across time zones), each holding a live WebSocket.
  • Channels: a big workspace has thousands of channels; average user is in ~40 channels. A huge #general/#announcements can have tens of thousands of members.
  • Messages: ~50M users × ~40 messages/day → ~2B messages/day. Average 2B / 86,400s ≈ ~23K messages/sec; round to ~30K/sec, with peak ≈ 3× → ~90K messages/sec.
  • Fan-out: average channel is small (tens), but a message to a 20,000-member channel is up to 20,000 deliveries — one post, many pushes.
  • Concurrent WebSocket connections: ~10M. At ~500K–1M per connection server, that is ~10–20 connection servers minimum, before headroom.

How the numbers affect the design

Signal from the numbers Design decision
~10M live connections A gateway layer of connection servers holding millions of WebSockets — not request/response web servers.
~30K–90K messages/sec, write-heavy A write-optimized store partitioned by channel, ordered by a per-channel sequence.
One post → up to 20K deliveries Treat channel fan-out as its own problem; deliver only to connected members via a registry.
Unread per user per channel Never scan messages to count unread — derive it from a per-channel sequence and a per-user read cursor.
Search across a workspace's history A separate inverted-index search store, not LIKE scans over the message table.
Everyone watches everyone's dot Presence must be subscription/pull-based, not broadcast to all N members on every change.

Storage

  • Per message: id (~16B), channel id (~16B), sender id (8B), sequence (8B), timestamp (8B), thread ref (~16B), body (~150B avg) ≈ ~220B; round to ~300B with overhead.
  • 2B messages/day × 300B ≈ ~600 GB/day before replication → ~200 TB/year and growing forever. This is why the store is partitioned and why old data tiers to cheaper storage; the search index is a separate, smaller structure.

Caution

Do not model the message store as one big SQL table scanned with WHERE channel = ? ORDER BY time. At ~90K writes/sec peak, hundreds of TB/year, and the access pattern "give me the last 30 messages in this channel," a store partitioned by channel and ordered by a per-channel sequence is the fit. A single relational table becomes the write bottleneck long before storage runs out.

Core entities / data model

Entity Key fields
Workspace workspace_id (PK), name, created_at
User user_id (PK), workspace_id, name, presence_status, last_active
Channel channel_id (PK), workspace_id, name, type (public/private/dm), latest_seq, created_at
Membership (channel_id, user_id), joined_at, last_read_seq — who is in a channel + their read cursor
Message message_id, channel_id, sender_id, seq, timestamp, body, thread_root_id?

The Message store is the heavy one, partitioned (sharded) by channel_id and ordered by seq, a per-channel sequence number. That layout makes the most common read — "the last 30 messages in this channel" — one sequential scan on one partition, no cross-machine join. It also makes ordering cheap: message ordering only has to hold within a channel, and seq is assigned per channel. A partition-leader store — a per-channel log (a Kafka topic per channel), HBase, or sharded Postgres — fits well because the leader assigns seq on the write path (see Deep Dive 1 for why the store choice and seq allocation are coupled).

The critical, cheap idea is the read cursor. Rather than store an "unread?" flag on every (message, user) pair — which would be billions of rows — each membership row holds one number: last_read_seq, the seq of the last message this user has read in this channel. Unread is then arithmetic, not a scan (Deep Dive 2).

Note

Separate three concerns: the durable channel log (source of truth, partitioned by channel), the per-channel latest_seq (what's the newest message — a field on the Channel row, or equivalently MAX(seq) on the channel's partition), and the per-user last_read_seq (what has this user seen). latest_seq is not a separate entity; it is bumped in the same atomic step as the message insert so it never lags the log. Unread is derived by subtracting the third from the second. Conflating "what was said" with "what have I read" is what pushes people toward the doomed per-message-per-user unread flag.

API design

Slack is not a plain REST API. Posting and fetching history are ordinary request/response, but receiving a channel message can't be a request — the client doesn't know when someone else will post. So the client opens one long-lived connection and the server pushes events down it. We describe that channel explicitly alongside the REST endpoints.

Real-time channel (persistent connection)

Client opens a WebSocket:  wss://slack.example.com/connect?token=...
  authenticates, subscribes to the user's channels, keeps the connection OPEN

Client to server events:
  { "type": "post",   "client_msg_id": "<uuid>", "channel_id": "...", "body": "..." }
  { "type": "read",   "channel_id": "...", "up_to_seq": 4471 }
  { "type": "ping" }                       heartbeat to keep the connection alive

Server to client events (pushed, no request):
  { "type": "message",  "channel_id": "...", "seq": 4472, "sender_id": "...", "body": "..." }
  { "type": "ack",      "client_msg_id": "<uuid>", "message_id": "...", "seq": 4472 }
  { "type": "presence", "user_id": "...", "status": "online" }
  { "type": "unread",   "channel_id": "...", "unread_count": 3, "latest_seq": 4472 }

The client opens one WebSocket at launch and keeps it open. The server pushes message, presence, and unread events whenever they happen, with no matching request — that is real-time receiving. A periodic ping heartbeat keeps the connection alive and lets the gateway detect a dead one within seconds. The client_msg_id is a client-generated idempotency key: if the network drops and the client resends, the server recognizes the same id and returns the original result instead of double-posting.

Post a message (HTTP fallback for the WebSocket post)

POST /api/channels/{id}/messages
Body:    { "client_msg_id": "<uuid>", "body": "...", "thread_root_id": null }
Returns: 201 { "message_id": "...", "seq": 4472, "timestamp": "..." }

The server assigns the authoritative seq and message_id, persists, then fans out.

Fetch history (cursor pagination)

GET /api/channels/{id}/messages?before_seq=4472&limit=30
Returns: 200 { "messages": [ ... seq 4442..4471 ... ], "next_before_seq": 4442 }

Load older messages for scrollback or a fresh device. It uses cursor pagination on seq, not page numbers, because new messages arrive constantly and offset pages would skip or repeat rows. This is one sequential read on the channel's partition.

List channels with unread (the sidebar on launch)

GET /api/channels
Returns: 200 { "channels": [ { "channel_id": "...", "name": "engineering",
                               "latest_seq": 4472, "unread_count": 3 }, ... ] }

Loads the sidebar with each channel's unread badge — computed from latest_seq − last_read_seq, never by scanning messages.

Search

GET /api/search?q=deploy+rollback&in=engineering&from=@carol
Returns: 200 { "results": [ { "message_id": "...", "channel_id": "...", "snippet": "..." }, ... ] }

Full-text search scoped to the caller's workspace, served by the search index (Deep Dive 3), not the message store.

High-level architecture

Three flows matter: posting to a channel and fanning out to online members, computing unread cheaply, and history + search reads. The shared cast of components:

  • Gateway (connection servers). Each holds a large number of long-lived WebSockets (e.g. ~500K–1M per server). Their only job is to keep connections open and shuttle events. This is what makes "millions online" possible; they are deliberately dumb pipes.
  • Connection registry. A fast lookup (e.g. Redis) that maintains two directions off the same membership data: user → {(device, gateway)} for routing a message to a specific user's sockets, and channel → {(user_id, gateway)} for fan-out (which connected members a channel has, and where). Updated on connect/disconnect and on channel subscribe.
  • Message service + message store. Validates and persists each message, assigns the per-channel seq, bumps latest_seq, and triggers fan-out. Store is partitioned by channel.
  • Presence service. Tracks online/away, updated by heartbeats, queried on demand (not broadcast to everyone).
  • Search indexer + search store. Consumes new messages and builds a per-workspace inverted index.

1. Post + fan-out path (members online)

Carol posts to #engineering; some members are connected to different gateways.

flowchart LR
    Carol[Carol's client] -->|"post (WebSocket)"| GWA[Gateway A]
    GWA -->|"persist + assign seq"| MSG[Message Service]
    MSG -->|"insert + bump Channel.latest_seq atomically"| Store[(Message Store - by channel)]
    MSG -->|"who is connected in this channel?"| Reg[(Connection Registry)]
    Reg -->|"members on B, C"| MSG
    MSG -->|"publish to gateways' topics"| Bus{{Event Bus / Pub-Sub}}
    Bus --> GWB[Gateway B]
    Bus --> GWC[Gateway C]
    GWB -->|"push"| M1[Members on B]
    GWC -->|"push"| M2[Members on C]
    MSG -.->|"async index"| Idx[Search Indexer]
  1. Carol's client sends post over its WebSocket to Gateway A.
  2. Gateway A hands it to the Message Service, which persists to the durable store and assigns the channel's next seqbefore acknowledging, so an accepted message is never lost — and bumps latest_seq.
  3. The service asks the connection registry which channel members are currently connected, and to which gateways.
  4. It forwards the message to just those gateways, each of which pushes it down its connected members' sockets. Offline members aren't touched now — they'll pull on reconnect and their unread badge reflects the new latest_seq.
  5. Asynchronously, the message is handed to the search indexer.

Note

"Forward to each gateway" is not the Message Service opening a TCP connection to every gateway. Forwarding rides an internal event bus / pub-sub (Kafka or Redis Pub/Sub) that gateways subscribe to; the service publishes, the relevant gateways receive. The service holds no direct sockets to the gateway fleet — that decoupling is what lets gateways come and go freely.

Note

The gateways are "dumb pipes": they hold connections and move events. All durable logic — persistence, sequencing, fan-out decisions — lives in the Message Service, so a gateway can die and its clients simply reconnect to another one without losing messages.

2. Unread + presence path

Unread is not pushed message-by-message as a count; it is derived. When a member reads a channel, the client sends a read up to some seq, which advances their cursor.

flowchart LR
    User[User opens #eng] -->|"read up_to_seq=4472"| GW[Gateway]
    GW --> MSG[Message Service]
    MSG -->|"set last_read_seq=4472"| Mem[(Membership - read cursor)]
    Chan[(Channel row - latest_seq)] -->|"latest_seq"| Calc[Unread = latest_seq - last_read_seq]
    Mem -->|"last_read_seq"| Calc
    Calc -->|"push updated badge"| GW
    Pres[Presence Service] -.->|"heartbeat online/away"| Reg[(Connection Registry)]

Unread for a channel is latest_seq − last_read_seq — a subtraction, computed on demand, never a message scan. Presence is maintained by heartbeats into the presence service and read on demand when a client actually looks at a member list.

3. History + search path

History is a sequential read on the channel's partition, served straight from the message store. Search takes a different route: a separate inverted index, scoped to the workspace, kept up to date by an indexer that tails new messages. Because they have different access patterns, they get different stores — detailed in Deep Dive 3.

4. Combined architecture

flowchart LR
    C1[Client A] --> GWA[Gateway A]
    C2[Client B] --> GWB[Gateway B]
    GWA --> MSG[Message Service]
    GWB --> MSG
    MSG --> Store[(Message Store - by channel<br/>Channel.latest_seq + read cursors)]
    MSG <-->|"connected members per channel"| Reg[(Connection Registry)]
    MSG -->|"publish"| Bus{{Event Bus / Pub-Sub}}
    Bus -->|"deliver to members' gateways"| GWB
    MSG -.-> Indexer[Search Indexer]
    Indexer --> Index[(Search Store)]
    Pres[Presence Service] <--> Reg
    GWA -.->|"history"| MSG
    GWA -.->|"search"| SS[Search Service]
    SS --> Index[(Search Index)]

The Message Service is the hub: it persists every message, assigns seq, decides which gateways to forward to (from the registry), maintains latest_seq and read cursors, and feeds the indexer. Presence and the registry are kept fresh by gateway heartbeats.

Tip

Lead with the small-channel online post, then add unread/presence, then history/search. Interviewers want the happy path first, then the harder cases — not a jump straight to a 20,000-member #general.

Deep dives

1. Real-time delivery and channel fan-out

The central mechanism is the persistent connection: a user keeps one WebSocket open to a gateway, and the gateway can push to them any time. The hard question: when someone posts to a channel, how do you get that message onto the sockets of the channel's connected members, spread across many gateways — without looping over the whole membership list every time?

Caution

The classic mistake: on every post, load the channel's full membership from the database and try to deliver to all of them. For a 20,000-member channel that's a 20,000-row read and 20,000 delivery attempts per message, most of them to offline users. Fan-out must go through a connection registry of who is actually connected, not a cold membership scan.

Route via a connection registry. When a client connects and subscribes to its channels, the gateway records, in a shared registry (Redis), that this user's socket lives here and that they're a live member of channels X, Y, Z. The registry can answer "which connected members does #engineering have, and on which gateways?" To deliver a post, the Message Service asks the registry, then forwards the message once per gateway that has interested members over an internal event bus (Kafka or Redis Pub/Sub) that the gateways subscribe to — the service does not hold a TCP connection to each gateway. Each gateway pushes to its local sockets. A message therefore touches only the handful of gateways that actually hold online members, not every server and not every member.

Per-channel ordering. Each channel has a monotonically increasing seq, assigned by the message store when it persists the message. Clients render by seq and request "everything after my last seq" on reconnect. This gives one consistent order for all members without trusting device clocks (which drift). Because ordering is only needed within a channel, seq is assigned per channel — which is exactly why partitioning the store by channel works.

Allocation needs a single point of decision per channel, and this is where the store choice bites. With a partition-leader store — a Kafka topic per channel, HBase, or sharded Postgres — the leader for that channel simply assigns the next seq on the write path, cheaply and in order; this is the clean fit and the recommended default. With a leaderless store (Cassandra-style, no partition leader), there is no natural single writer, so per-channel seq requires either a lightweight transaction (a CAS insert, which coordinates across replicas and costs a round of consensus per write) or an external per-channel sequencer (a small sharded counter service) in front of the write. Because that coordination cost recurs on every message, a partition-leader store is preferred here; if you do pick a leaderless store, name the CAS/sequencer explicitly rather than hand-waving "the store assigns it."

Now the size split, which is the crux of fan-out:

Small/medium channels — direct fan-out to connected members (default)

The Message Service resolves the channel's connected members from the registry and forwards the message to each gateway holding one, which pushes to those sockets. Offline members get nothing now; they see the message (and an incremented unread badge) when they reconnect and pull history after last_read_seq.

flowchart LR
    Post[Post to #team] --> MSG["Message Service<br/>persist, seq=512"]
    MSG -->|"registry: online members on B, C"| Reg[(Registry)]
    MSG -->|"forward once"| GWB[Gateway B]
    MSG -->|"forward once"| GWC[Gateway C]
    GWB -->|"push"| S1[online members]
    GWC -->|"push"| S2[online members]
    style MSG fill:#dcfce7,stroke:#16a34a,color:#14532d
  • Pro: simple, low-latency, and cost scales with online members, not total membership. Perfect for the vast majority of channels (tens to low hundreds of members).
  • Con: cost still grows linearly with the number of connected members; for a channel with tens of thousands online, one post is tens of thousands of pushes.
  • Verdict: the right default up to some size threshold.
Huge channels (#general, tens of thousands online) — per-channel pub/sub (needs special handling)

For a 20,000-member channel where most are online, resolving members and issuing tens of thousands of individual forwards per message is a bottleneck — especially if the channel is chatty.

 #general: 20,000 online members, 3 messages/sec
   -> 20,000 x 3 = 60,000 pushes/sec  for ONE channel

Above a threshold — roughly O(1,000) simultaneously-online members — switch that channel to a pub/sub model: every gateway holding a member of the huge channel subscribes to that channel's topic; the Message Service publishes the message once, and each subscribed gateway fans out locally to its own sockets. The service issues one publish instead of thousands of forwards; the per-socket fan-out happens at the edge, spread across gateways.

The switchover itself must be lossless: for a brief window during the transition, publish each message to both the direct-forward path and the new topic, so a message in flight while gateways are still subscribing is delivered by one path or the other and nothing slips through the gap. Clients dedupe on seq, so the temporary double-send is harmless; once all relevant gateways are subscribed, drop the direct path.

  • Pro: the service does O(1) work per message regardless of channel size; edge gateways parallelize the last-hop pushes.
  • Con: more moving parts (topic management, a bus to scale); receipts/typing indicators are usually dropped for huge channels.
  • Verdict: necessary above a size threshold; below it, direct fan-out is simpler and fine.

Backpressure. Each gateway keeps a bounded outbound buffer per socket. On a hot channel, one slow client can't drain fast enough; rather than buffer without limit (which would exhaust the gateway's memory and hurt every other client on it), the gateway closes that slow socket when its buffer overflows. The client detects the drop and reconnects, then recovers by requesting everything after its last seq from the channel log — so the "drop" costs one reconnect, not lost messages.

Reconnect thundering herd. When a gateway dies, the ~1M clients it held all reconnect at once — a thundering herd that hammers the registry. Mitigate with randomized exponential backoff (jitter) and by draining a server's connections gradually when taking it out of rotation. To drain a gateway for a deploy, stop accepting new connections first, let the load balancer remove it from rotation, then give existing clients a reconnect window (e.g. 30–60 s with jitter) to migrate to other gateways before shutdown — so drain is gradual, not a mass disconnect.

2. Presence and unread / read state at scale

These are the two "cheap-looking, secretly expensive" features. Both fail the same way — by doing O(members) or O(messages) work where a small trick makes it O(1).

Unread via the read cursor. The naive design stores an unread flag per (message, user): billions of rows, and every post writes one row per member. Instead, keep one number per membership: last_read_seq. The channel already tracks latest_seq. Then:

 unread_count(user, channel) = latest_seq(channel) - last_read_seq(user, channel)

Opening a channel sends a read event with the newest visible seq; the server sets last_read_seq to it and the badge clears. "Jump to first unread" is just "scroll to last_read_seq + 1." No message scan, one row updated per read (not per message).

The subtraction assumes seq is gap-free. Once messages can be deleted, a gap opens and latest_seq − last_read_seq slightly over-counts — it's a badge, an approximation, not a promise of exactly N still-present messages. For an unread indicator that's fine; nobody audits the badge against the message list.

Per-message-per-user unread flags (avoid)

Store a row per (message, user) with a boolean read. On each post, insert one unread row for every member; on read, flip them.

The consequence: a post to a 20,000-member channel writes 20,000 rows. A busy workspace generates billions of these rows, and marking a channel read flips thousands at once. The write amplification alone dwarfs the actual message writes.

  • Pro: trivially answers "did this user read this message."
  • Con: catastrophic write volume; unbounded row growth; slow mark-as-read.
  • Verdict: avoid. You almost never need per-message read state — a single cursor answers "how many unread" and "where do I resume."
Read cursor + per-channel latest_seq (recommended)

One last_read_seq per (user, channel) and one latest_seq per channel. Unread is a subtraction; mark-as-read is one update. The cursor lives on the membership row, so listing the sidebar is one read per channel the user is in (or a single batched read).

  • Pro: O(1) unread per channel, O(1) mark-as-read, tiny storage. Naturally consistent across a user's devices because the cursor is server-side — read on your phone, and your laptop's badge clears on its next unread push.
  • Con: gives a count, not "which specific messages are unread beyond position." That's fine — Slack shows a count and a "new messages" line at the cursor, nothing finer.
  • Verdict: the standard approach. Push an unread event when latest_seq advances so badges update live without a poll.

Note

Mentions are the one thing you do want finer than a count — the red badge for "someone @mentioned me." Handle that with a tiny separate per-user mention counter incremented at fan-out time when the message text targets that user, not by scanning messages. It's a second cheap counter, not a return to per-message flags.

Presence without a broadcast storm. Presence is the green dot. The trap: treat every online/away change as an event to broadcast to everyone who might see that user. In a 50,000-person workspace where everyone can see everyone, one person going idle would fan out to 50,000 clients — and everyone toggles constantly.

Broadcast every presence change to all members (avoid)

On each online/away/offline transition, push a presence event to every user in the workspace.

The consequence: presence changes are frequent (idle timeouts, tab switches, laptops sleeping). In a large workspace this is a continuous storm of O(N) pushes per change, dwarfing real message traffic, for information most clients aren't even looking at.

  • Pro: every client always has fresh presence for everyone.
  • Con: O(N) per change, mostly wasted — you don't see 50,000 dots at once.
  • Verdict: avoid. Nobody is looking at most of those dots.
Subscribe/pull presence for the visible set (recommended)

A client only needs presence for the handful of users it's actually rendering — the current channel's members, a DM list, the people in view. The client subscribes to presence for that small visible set (or pulls it on demand when it opens a member list). The presence service, backed by the connection registry plus heartbeat timers, answers those targeted queries and pushes changes only to subscribers who care.

Heartbeat mechanics. The gateway sends a heartbeat per connected user to the presence service every N seconds (tens of seconds), and each heartbeat carries a TTL. As long as heartbeats keep arriving the user is online; if none arrives within the TTL, the socket is presumed dead and the user drops to offline. Distinguish the two "not-green" states: offline means no active socket at all, while away means the socket is alive (heartbeats still flowing) but the client has seen no activity for a longer idle window, or the client explicitly sent an "away" event. So presence is derived from two signals — socket liveness (heartbeat/TTL) and activity — not from a single flag.

flowchart LR
    Client[Client opens #eng] -->|"subscribe presence: 30 visible users"| Pres[Presence Service]
    Reg[(Connection Registry + heartbeats)] --> Pres
    Pres -->|"status for those 30"| Client
    Pres -.->|"push only when a subscribed user changes"| Client
  • Pro: work scales with what's on screen (tens), not workspace size (tens of thousands). Presence derives directly from connection state + heartbeat freshness.
  • Con: slightly stale for users you're not subscribed to (fine — you're not looking); needs subscription management as the user navigates.
  • Verdict: the standard approach. Presence is best-effort and ephemeral — never persist it durably, and throttle updates.

Two different reads live here, and they want two different stores.

Storage + history. The channel log is partitioned by channel_id and ordered by seq. "Last 30 messages in this channel" is one sequential read on one partition; scrollback pages backward with cursor pagination on seq. Because a channel is the partition key, a hot channel's reads and writes stay on its shard and don't contend with the rest of the workspace. Old messages tier to cheaper cold storage — history reads for ancient messages are rare and can tolerate higher latency, while the recent tail stays hot.

Note

Partition by channel, not by user. A message belongs to one channel and is read "give me this channel's recent messages"; storing it once per channel (not copied into each member's per-user timeline) keeps writes O(1) per message and makes scrollback a single-partition read. The per-user view (sidebar, unread) is reconstructed cheaply from cursors, not from a per-user copy of every message.

Search. History answers "recent messages in this channel"; search answers "messages containing these words anywhere in my workspace" — a completely different access pattern the channel-partitioned store can't serve without scanning everything.

Search by scanning the message store (avoid)

Run a LIKE '%deploy%' / full-table scan across the message partitions when a user searches.

The consequence: the store is partitioned by channel and ordered by seq, with no index on body text. A workspace-wide keyword search would scan every channel's partition — hundreds of GB — per query. It's slow and it competes with live message writes for the same resources.

  • Pro: no extra system to build.
  • Con: unusable latency; scales with total message volume per query; hammers the write path.
  • Verdict: avoid. Text search needs an inverted index, not a scan.
Separate inverted-index search store, workspace-scoped (recommended)

An indexer tails the message stream (the same feed the fan-out reads, or a change log off the store) and builds an inverted index (Elasticsearch/OpenSearch), sharded by workspace so a query only touches its own org's data and workspace isolation is structural. Each indexed doc carries channel_id, sender_id, and timestamp so results can be filtered by channel/author/date and permission-checked (drop channels the searcher can't see).

flowchart LR
    Store[(Message Store)] -.->|"change stream"| Indexer[Search Indexer]
    Indexer -->|"index by workspace shard"| ES[(Inverted Index)]
    Q[Search query] --> SS[Search Service]
    SS -->|"query workspace shard + filters"| ES
    ES -->|"matching message ids + snippets"| SS
    style ES fill:#dcfce7,stroke:#16a34a,color:#14532d
  • Pro: keyword search is O(matches), not O(all messages); filters (channel, author, date) are cheap; isolated per workspace; decoupled from the write path so indexing lag never blocks posting.
  • Con: a second store to run and keep consistent; search is eventually consistent — a just-posted message may take a moment to become searchable. That's an acceptable tradeoff (you rarely search for something you sent one second ago).
  • Verdict: the standard approach — real-time delivery from the log, search from a separate index, kept in sync asynchronously.

Warning

Enforce permissions at search time, not just at index time. A user must not find messages in private channels they aren't a member of. Filter results by the searcher's channel memberships (or index with an ACL field and constrain the query), so search never leaks content across visibility boundaries within a workspace.

Tradeoffs & bottlenecks

  • Persistent connections vs request/response. Holding ~10M live WebSockets is the defining cost: gateways are stateful (they "own" their clients), need careful load-balancing on connect, and must hand clients off cleanly on drain or crash. This is the price of sub-second push.
  • Fan-out via registry vs membership scan. Delivering only to connected members from a registry is far cheaper than scanning full membership per post — but the registry must stay fresh (heartbeats, TTLs) or you push to dead sockets. The durable log is the backstop: a missed push is backfilled on reconnect.
  • Direct fan-out vs pub/sub for huge channels. Direct forwarding is simple and best for the common small channel; per-channel pub/sub is O(1) service-side work for #general-scale channels but adds topic/bus machinery. Choose by size threshold.
  • Read cursor vs per-message unread. A single last_read_seq gives O(1) unread and mark-as-read but only a count, not per-message granularity. For team chat that's exactly right; a mention counter covers the one finer case.
  • Push presence to all vs subscribe/pull. Broadcasting presence is O(members) per change and mostly wasted; subscribing to the visible set is O(on-screen) and slightly staler for users you aren't watching — the right trade.
  • Real-time vs eventually-consistent search. Delivery comes from the log instantly; the search index lags by an async hop. Accepting search staleness keeps the write path fast and the index decoupled.
  • Store per channel vs per user. Partitioning by channel keeps writes O(1) and scrollback single-partition; a per-user copy of every message would multiply writes by membership and is unnecessary because cursors reconstruct the per-user view.

Extensions if asked

Add only the extension that changes the design discussion:

  • Threads. A reply carries a thread_root_id; thread replies are stored in the same channel partition but rendered as a side conversation. Thread participants get their own notifications; the main channel optionally shows a compact "N replies" summary rather than each reply inline.
  • Reactions / emoji. A reaction is a tiny row keyed by (message_id, user_id, emoji); aggregate counts are pushed as lightweight events. High-frequency, low-value — throttle and never treat as a message.
  • Mentions + notifications. @user and @channel drive push notifications and the red mention badge. Resolve targets at fan-out time and increment per-user mention counters; for offline users, route to APNs/FCM as a wake-up hint (the durable message is the source of truth, not the push).
  • Huge #general / #announcements. Read-mostly broadcast channels benefit from pub/sub fan-out, downgraded receipts, and rate-limited posting.
  • Guest and shared/Connect channels. A channel shared across two workspaces needs per-workspace visibility rules and careful search-permission filtering so neither org sees the other's private data.
  • Enterprise multi-workspace (Enterprise Grid). One org spans many workspaces with shared identity and org-wide channels; presence, search, and membership must span workspaces while preserving per-workspace isolation.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Persistent connections (WebSocket) through a horizontal gateway layer, with a clear explanation that receiving a channel message can't be plain REST — the server must push.
  • Channel fan-out via a connection registry — deliver only to connected members, forwarding once per gateway, not by scanning full membership on every post.
  • Per-channel ordering via sequence numbers, enabling channel-partitioned storage and single-partition scrollback.
  • Unread computed from a read cursor (latest_seq − last_read_seq), not per-message-per-user flags or message scans.
  • Presence handled by subscribe/pull for the visible set, not broadcast to the whole workspace.
  • A separate search index, workspace-scoped and permission-filtered, kept in sync asynchronously — not LIKE scans over the message store.
  • History via cursor pagination on seq, not offset pages — offset pages skip or repeat rows as new messages arrive, while a before_seq cursor stays stable.

Before you finish, do a quick mistake check:

  • Did you use persistent connections and a horizontal gateway layer, not one giant connection server?
  • Did you fan out via a registry of connected members, instead of scanning membership on every message?
  • Did you handle huge channels (#general) as a distinct, harder case than a small channel?
  • Did you order messages per channel with sequence numbers, not device clocks?
  • Did you compute unread from a read cursor, not by scanning messages or storing per-message flags?
  • Did you keep presence cheap with subscribe/pull, not a broadcast to every member?
  • Did you serve history from a channel-partitioned store and search from a separate, workspace-scoped index?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →