YeetCode
System Design · Advanced Designs

Design Slack: Channels, Threads, and Search That Stays Fast

How to design Slack in a system design interview — the WebSocket gateway, thread data model, permission-aware search, boot payload, and notification fan-out.

11 min readBy Bhavesh Singh
slackteam messagingwebsocket gatewaymessage searchthreadsnotification fanout

This article has an interactive companion

Don't just read it — step through it interactively, one state change at a time.

Practice this topic on YeetCode

Slack looks like a chat app, and the messaging part is genuinely the easy half. The hard engineering hides in three places: opening the app fast when you belong to 400 channels, finding a message from six months ago in under a second without ever showing you a channel you can't read, and deciding — for every single message — whether each of a thousand recipients gets a phone buzz, a red badge, an email tomorrow morning, or nothing at all.

That mix is why "Design Slack" is a favorite Advanced-tier prompt. It shares a real-time core with Discord, but the pressure points are completely different: workspace isolation instead of massive public guilds, permission-filtered search instead of voice, and a client cold-start problem that turns out to be the single biggest latency lever in the product.

This guide walks the full interview rubric — functional requirements, capacity math with real numbers, the high-level architecture, the thread-aware data model, and the deep dives (boot payload, search, notifications) where senior candidates pull ahead.

Functional and non-functional requirements

Scope before you draw boxes. The functional core of Slack is:

  • Workspaces and channels — a workspace is an isolation boundary; inside it users join public channels, private channels, and one-to-one or group DMs.
  • Messaging with threads — post to a channel, and reply inside a thread on any message without cluttering the main timeline.
  • Search — full-text search across every message and file the user is allowed to see, fresh within seconds.
  • Notifications — mentions, keyword alerts, and DMs drive mobile push, desktop badges, and email digests, gated by per-user preferences and Do-Not-Disturb.
  • Integrations — bots, slash commands, and incoming webhooks post messages like any other member.

The non-functional requirements decide the architecture:

RequirementTargetWhy it drives the design
Message delivery< 200ms to online membersPersistent WebSockets, not polling
Cold-start (app open)< 1s to usableA single "boot" payload, not 100 API calls
Search freshnessMessage findable 1–2s after postingNear-real-time indexer with a short refresh
Search correctnessNever leak a channel you can't readACL filters materialized into every index doc
Availability99.9%+Stateful gateway must tolerate node loss
IsolationWorkspace A never sees workspace BPer-workspace partitioning end to end

The read/write shape matters. Channel history is append-heavy and read newest-first; search is read-heavy against an inverted index; presence and notification preferences are update-heavy, read on demand. Those three want different stores, and forcing them into one is the classic mistake.

Capacity estimation

Put numbers down early — they justify every later choice. Model one large Enterprise Grid customer: 500K employees, 80% daily active, each reading ~200 messages and sending ~30 per day, with load peaking around 10am local time.

Writes. 400K DAU × 30 sent = 12M messages/day, roughly 140/sec averaged over 24 hours. Traffic is not flat, though — the 10am peak runs about 10× the average, so plan for 1,400–2,000 writes/sec. That is comfortable for a horizontally partitioned store like Cassandra or Vitess; nothing exotic required.

Reads. 400K × 200 = 80M reads/day. At the same peak multiplier that's roughly 8,000 reads/sec, and almost all of it should hit cache before it ever reaches the message store — recent channel history is the hottest, most re-read data in the system.

Storage. 12M messages/day at ~1 KB each (content plus IDs, timestamps, reactions, edit metadata) is ~12 GB/day of raw text, about 4.4 TB/year before attachments. Attachments dominate — files run 10–50× the text volume — so budget 50–200 TB/year for a single large customer. The search index adds roughly 30% of the raw text size on top.

The takeaway for the interview: message writes are trivially small, but files and the search index are where the storage bill and the operational pain actually live.

High-level architecture

The center of gravity is the gateway — Slack calls its version "flannel." It's a fleet of stateful servers each holding tens of thousands of persistent WebSocket connections. Each connection carries per-user subscription state: which channels this user watches, their presence, and a sequence cursor marking the last event they've seen.

text
┌──────────────┐ clients ◄──────► │ Gateway │ (WebSocket / "flannel", stateful, (WS) │ fleet │ holds per-user subscriptions) └──────┬───────┘ │ publish / subscribe ┌──────┴───────┐ ┌──────────┤ Event bus ├──────────┐ │ │ (Kafka) │ │ ▼ └──────────────┘ ▼ ┌───────────────┐ ┌────────────────┐ │ Message svc │ │ Notification │ │ (Cassandra/ │ │ fanout svc │ │ Vitess) │ └────────────────┘ └───────┬───────┘ ┌──────────────┐ └───────────────►│ Search index │ (per-workspace ES, async index off write │ (Elastic) │ ACLs on every doc) └──────────────┘ Supporting: Auth/workspaces · Files+CDN · Integrations/webhooks · Boot service

The flow for a posted message:

  1. Client sends over its WebSocket to a gateway node.
  2. Gateway calls the message service, which assigns a monotonic timestamp/ID and writes to the channel's partition.
  3. The write is published to an event bus (Kafka). Gateway nodes holding subscribers for that channel receive it and fan it out to their local connections.
  4. Two consumers run off the same log asynchronously: the search indexer and the notification fanout service. Neither blocks the send path.

Everything is scoped by workspace. A message write, a search query, and a notification decision all carry a workspace ID that partitions the data and enforces isolation.

The data model: making threads cheap

The dominant read is "the latest N messages in this channel, newest first," so partition by channel and cluster by time:

sql
CREATE TABLE messages ( channel_id bigint, ts bigint, -- server timestamp, also the message id author_id bigint, content text, parent_ts bigint, -- NULL for top-level, set for a thread reply reply_count int, -- denormalized onto the PARENT row last_reply_ts bigint, -- denormalized onto the PARENT row PRIMARY KEY ((channel_id), ts) ) WITH CLUSTERING ORDER BY (ts DESC);

Threads are the interesting part. If you stored replies as ordinary messages, rendering the channel would mean fetching hundreds of replies you don't want to show inline. Instead:

  • Top-level messages live in the channel partition and are the only rows the main timeline reads.
  • Replies carry a parent_ts so a thread read is a secondary lookup — "give me every message whose parent is this timestamp."
  • reply_count and last_reply_ts are denormalized onto the parent row, so the channel view can render "12 replies · last reply 3m ago" without touching a single reply.

The write path for a reply does two things in one batch: it inserts the reply row and bumps the parent's reply_count and last_reply_ts. Batching them keeps the counter consistent with the actual replies. Active threads are bursty — a hot thread gets re-read constantly for a few minutes — so they sit behind a per-thread cache, which absorbs almost all of that traffic.

How does Slack make message search hard?

Standard Elasticsearch indexing is the easy 20%. Three things make Slack search a genuine design problem:

Permissions. Every query must be filtered to exactly the channels this user can see — public channels they've joined, private channels they belong to, and DMs with per-user ACLs. You can't post-filter results after ranking; a hidden channel's message must never even be a candidate. The fix is to materialize ACL fields onto every indexed document and apply a query-time filter against the user's channel set (which is cached, because recomputing it per query would be expensive).

Freshness. Users expect to find a message a second or two after it's posted. That forces a short commit/refresh interval (1–2s) on the indexer, trading a little indexing throughput for near-real-time visibility.

Enterprise Grid. A single company can span many linked workspaces, and search has to span them while still respecting each workspace's boundaries. You index into per-workspace indices, then use time-based index rollover so old months roll to cheaper storage and huge customers don't sit on one unbounded index.

Deep dive: the boot payload

The boot payload is the highest-leverage optimization in the whole product, and it's a great interview signal because it's non-obvious.

When you open Slack, the client needs your channel list, unread counts, member data, and team state to render anything at all. Doing that with a hundred separate API calls would take seconds of cold-start latency. Instead the server returns one large "boot" payload — channels, members, counts — plus the delta of events since the sequence number the client last saw. After boot, every further update streams over the WebSocket.

The trap is that a naive boot is linear in workspace size: a 400-channel workspace pays for 400 channels every launch. Two changes fix it:

  • Lazy loading — only channels you're actively looking at hydrate fully; the rest arrive as lightweight stubs.
  • Incremental boot — the client sends its last-seen cursor against a workspace-wide event log, and the server returns only what changed since then.

Together those turn a linear-in-workspace-size operation into one that's linear in unread deltas — you pay for what changed while you were away, not for the whole workspace.

Deep dive: the notification pipeline

When a message is posted, someone has to decide, per recipient, between four outcomes: WebSocket-only (you're already looking), desktop badge, mobile push, or email digest. That fan-out runs asynchronously off the event bus so the send path never waits on push delivery.

The fanout service resolves the recipient set — channel members, @mentions, and personal keyword triggers — and for each person checks four things:

SignalEffect on delivery
Presence (online/offline)Online → WebSocket event, usually no push
Notification preferences"Mentions only" suppresses non-mention pushes
Do-Not-Disturb windowHolds or drops the push until DND ends
Device tokens (APNs/FCM)Offline users get a collapsed mobile push

Dormant users — not currently online and not worth a push — get batched into an email digest by a scheduled job. Running the whole thing over Kafka means a slow push provider or a burst of mentions can't back up the message write; delivery degrades independently of posting.

How this shows up in interviews

"Design Slack" is usually open-ended, and interviewers probe a handful of specific instincts.

First, do you reach for persistent connections and treat the gateway as stateful? Losing a gateway node drops live connections, so you should describe graceful drain — a node stops accepting new connections, tells its clients to reconnect elsewhere, and waits for them to migrate. Session state is reconstructable from each client's last-seen cursor, so you don't replicate session state across nodes; reconnects are cheap.

Second, do you realize search — not messaging — is the hard subsystem, and can you name the three reasons (permissions, freshness, Enterprise Grid) plus the ACL-on-every-doc fix? Third, can you explain the boot payload and why incremental boot is what makes cold-start scale? Fourth, on notifications, do you keep the fan-out async and gate on presence, preferences, and DND?

Strong candidates also volunteer a tradeoff they chose not to make. The obvious one is end-to-end encryption. Slack's EKM (Enterprise Key Management) is deliberately not E2E — it's server-side encryption with customer-managed keys, so admins can revoke access, but Slack still decrypts to power search, DLP, compliance export, and bot integrations. True E2E would break all of those, and enterprise buyers value auditability over E2E — so the pragmatic answer is EKM plus strict TLS, not client-side encryption. Naming that tradeoff out loud is often worth more than another feature box.

Further learning

Related system designs that reuse these building blocks:

  • Design Google Drive — the attachment-and-sync storage side that Slack leans on for file uploads, where files dominate the storage bill exactly as they do here.
  • Design Ticketmaster — another fan-out-under-contention problem, where the bottleneck is concurrency rather than search freshness.
  • Design a Payment System — where the consistency and durability guarantees that chat can relax become the entire problem.
  • Design a Search Engine — the inverted-index and ranking machinery behind Slack's search, at web scale.

FAQ

Why is Slack's WebSocket gateway a stateful service?

Each connection holds per-user subscription state — which channels the user watches, their presence, and a sequence cursor tracking the last event they've seen — so a lost node drops every live connection it held. The safe deployment pattern is graceful drain: the node stops accepting new connections, signals its clients to reconnect to another node, and waits for them to migrate before shutting down. You don't replicate session state across nodes because it's cheaply reconstructable — a reconnecting client sends its last-seen cursor and the gateway replays only the events it missed.

How do you keep Slack search from leaking private channels?

By making permissions part of the index, not a post-filter. Every message document carries materialized ACL fields describing which channels and users may see it, and every query includes a filter against the requesting user's cached set of readable channels. Because the filter runs at query time before ranking, a message from a private channel or a DM the user isn't in never becomes a candidate result. Post-filtering after ranking would be both slower and risk leaking result counts or snippets, so the ACL has to live on the document itself.

What is the boot payload and why does it matter so much?

When the app launches it needs your entire channel list, unread counts, and team state before it can render, and fetching that with dozens of separate API calls would cost seconds of cold-start latency. The boot payload collapses all of it into a single server response, plus the delta of events since the client's last-seen sequence number; everything after that streams over the WebSocket. It's critical because it's the dominant cold-start cost — and with lazy loading (only active channels fully hydrate) and incremental boot (send only what changed since the cursor), it scales with your unread deltas instead of with the size of the whole workspace.

How does Slack decide between a mobile push, a badge, and an email?

A fanout service reads posted messages off the event bus and resolves the recipients for each one — channel members plus anyone @mentioned or hit by a personal keyword trigger. For every recipient it checks presence, notification preferences, the Do-Not-Disturb window, and registered device tokens. Online users get a WebSocket event and usually no push; offline users get a collapsed APNs/FCM push; and dormant users are batched into a scheduled email digest. The pipeline runs asynchronously through Kafka so the message-post path is never blocked waiting on push delivery.

Why doesn't Slack Enterprise use true end-to-end encryption?

Because true E2E would break the features enterprises actually pay for. Slack's EKM is server-side encryption with customer-managed KMS keys — admins can revoke access, but Slack itself still decrypts content to power full-text search, data-loss prevention, eDiscovery and compliance export, and bot integrations that need plaintext. A real MLS-style E2E scheme would blind all of those. Since enterprise buyers weigh auditability and compliance above E2E, the pragmatic design is EKM plus strict TLS and bring-your-own-key, rather than client-side encryption that no server-side feature could see through.

Make it stick: run this one yourself

Don't just read it — step through it interactively, one state change at a time.

Practice this topic on YeetCode