~learn.

Side quest 36Workplace messaging

Slack.

EXPERT53 MIN READ6 PHASES · 45 MIN INTERVIEW+120 XP

Mission brief

Design a workplace messaging platform: channels across workspaces and companies, real-time delivery, sharded storage, search, notifications and background work

Quest log0/7 stages · 0/8 checkpoints · 0/8 quick checks
simultaneously connected users (Slack, March 2020)
12.5 million
of connected clients; messages delivered "across the world in 500ms" (Slack Engineering, April 2023)
Tens of millions
database queries a second at peak on Vitess, p99 11 ms (Slack Engineering, December 2020)
2.3 million
background jobs on the busiest days, peaking at 33,000 a second (Slack Engineering, December 2017)
1.4 billion

Company names and logos are trademarks of their owners, shown only to identify each company. This course isn't affiliated with or endorsed by any of them.

watch it first · newThe whole design in a 16-minute animated lecture4 quiz checkpoints · ask the AI about any moment · captions, chapters and speed
Optional · timed run · 6 phases · 45 min

Want it harder? Hide the model answer and work each phase in a scratchpad against its time budget, then reveal and score yourself. Or simply read on: the checkpoints below work either way.

Intro · 4 min

How Do You Run One Chat App for a Million Separate Companies, and Let Them Talk to Each Other?

It is Monday 5 January 2026 in Bengaluru, the first Monday of the year. At 9:52 am Meera Iyer, who runs payments reliability at Paylane, a 6,000-person fintech, opens her laptop for the first time in two weeks. Slack shows its loading screen a little longer than usual. Paylane's Slack is an Enterprise Grid org with fourteen workspaces (Engineering, Ops, Sales, one per city office), and Meera is in nine of them.

At 9:58 a dashboard alert lands in #inc-payments: the morning settlement file from Paylane's partner bank has not arrived. Meera posts "@here settlement file from Kaveri Bank missing, joining the bank's on-call now", and #inc-payments goes bold in 900 sidebars; every member who is active right now, at a laptop or with Slack open on a phone, gets a notification. She switches to #paylane-kaveribank, a channel shared with the bank's own Slack, where the bank's on-call engineer is already typing. Then she searches "settlement file timeout" and finds the thread from last March where the same thing happened, with the fix pinned at the bottom.

At 10:00:00 thousands of reminders fire across Paylane at once ("Stand-up in 15 minutes"). Nobody notices. The messages, the shared channel, the search and the buzzing phones all just work, across two companies and fourteen workspaces, on a morning when half the world is reconnecting with empty caches.

Slack, from published numbers
  • 12.5 million simultaneously connected users on 25 March 2020, up from 10 million on 10 March; messages sent per user per day up about 20% between 1 February and 25 March (Slack press release, 26 March 2020)
  • Tens of millions of connected clients; about 16 million channels per channel server at peak; delivery "across the world in 500ms" (Slack Engineering, April 2023)
  • More than a billion messages sent a week; largest customers with 160,000+ active users and 5,000+ shared channels (Slack Engineering, September 2019)
  • 2.3 million queries a second at peak (2 million reads, 300,000 writes), median 2 ms, p99 11 ms, on Vitess (Slack Engineering, December 2020)
  • 1.4 billion jobs on the busiest days, at a peak of 33,000 a second, through a job queue used for "every Slack message post, push notification, URL unfurl, calendar reminder, and billing calculation" (Slack Engineering, December 2017)
  • 4 million simultaneous connections and 600,000 client queries a second at Flannel, the edge cache (Slack Engineering, May 2017)

For context:
Older Slack posts and APIs say "team" where today's product says "workspace"; this quest uses "workspace", and "org" for an Enterprise Grid organisation of many workspaces
Meera, Paylane, Kaveri Bank and every number about them are illustrative
Slack Technologies is now "a Salesforce company", and its engineering posts give dated figures rather than current user counts; the numbers above come from Slack's own posts and releases between 2017 and 2023, and each is dated. Everything else here, and every number labelled as an assumption, is an illustrative design

This is the system we'll design today.

ExplainerThe whole quest: Slack

Slack had 12.5 million simultaneously connected users in March 2020 and by 2023 served tens of millions of connected clients, delivering messages across the world in 500 ms; its Vitess database peaked at 2.3 million queries a second (p99 11 ms) and its job queue at 1.4 billion jobs on the busiest days. On stated assumptions that is ~30 million websockets, ~20,000 messages a second in a peak minute and ~2.4 million deliveries a second. The webapp checks membership and writes each message to MySQL behind Vitess, sharded by channel, before anyone sees it; the channel server that owns the channel's ID on a consistent-hash ring (about 16 million IDs per server) sends one copy to each subscribed gateway server, which pushes it to its websockets. The Flannel edge cache boots clients with a slim payload, shared by a workspace's clients in each region. A shared channel, up to 250 organisations, keeps one copy on the owner's shard with a per-side row for its name and privacy, and every access check asks about membership. A dead channel server is replaced in under 20 seconds, a delay for a sliver of everyone. Membership is sharded by user and by channel after the 22 February 2022 scatter-query overload; jobs go to Kafka first and a relay feeds Redis at a rate workers can take, so a backlog is lag on disk. Notifications are decided per member and device, and search filters by the channels the searcher can read inside Solr. The edge cases are Meera's first Monday, stale load-balancer slots, a half-failed zone and the 4 January 2021 Transit Gateway outage.

The Interview Begins#

You're interviewing at a company that builds workplace software. The interviewer shares a screenshot: a column of workspace icons on the left, a sidebar with "#inc-payments" in bold and a red "3" next to "#paylane-kaveribank", a small two-building icon beside that channel's name, a message with "@here" highlighted, and a search box at the top reading "settlement file timeout".

Interviewer

"Everything on this screen crosses a boundary. The sidebar mixes channels from nine workspaces. That channel with the little icon belongs to two different companies. The search box has to find last year's message, but only ones this user may read. And the red badge is right on her phone too. Design it: workspaces and orgs, shared channels, real-time delivery, storage, search, notifications and the background work behind them."

Design a workplace messaging platform at Slack scale

Organisations sign up as workspaces; large ones run an org of many workspaces. Users join channels (public or private) and DMs, inside one workspace, across an org's workspaces, or shared with other organisations. Every message is stored before it is shown, kept according to the organisation's retention policy, and searchable by each user over exactly what they can read. Every connected client sees new messages and events (reactions, edits, members joining, typing) in real time; people who aren't looking get notifications on their phones and desktops by rules they control. Messages trigger background work: notifications, link previews, indexing, reminders.

Requirements
  • Workspaces, orgs of many workspaces, and channels shared between up to hundreds of organisations
  • Post a message; store it, then deliver it to every connected member within about 500 ms
  • Hundreds of event types, plus transient ones (typing) that are never stored
  • Search messages and files, ranked, limited to what the searcher may read
  • Notifications for mentions, DMs and keywords, by user preferences, on every device
  • Background jobs (notifications, unfurls, reminders, indexing) that never block a send
Interviewer

"Leave out huddles (audio and video), Canvas, the app directory and its rate limits, and the AI features. And you've seen Discord's design in this series; I don't want it again. Tell me what is different about a workplace."

Phase 1 · Stage 1 of 7 · 5 min budget · 3 min read

Phase 1: Requirements Clarification (5 minutes)#

You

"Discord's hard problem was a community of strangers in one huge server, where a single process per server orders everything and the total fan-out work grows with the square of the people online. Here the hard boundary is the customer. A workspace is a tenant with its own admins, retention rules and sometimes its own encryption keys, and yet channels now cross workspaces and companies. So I'll ask about tenancy first, then about scale and what 'real time' has to mean."

Your Questions#

Transcript
You

"How many people are connected at once?"

Interviewer

"Slack doesn't publish regular user counts. Its best-known official figure is 12.5 million simultaneously connected users on 25 March 2020, up from 10 million two weeks earlier, when offices emptied. In 2023 its engineers wrote that the real-time servers serve 'tens of millions of connected clients'."

You

"What's a tenant? Is everything inside one workspace?"

Interviewer

"It used to be. Slack's engineers wrote that when a workspace was created, 'it was assigned to a specific database shard, messaging server shard, and search service shard'. Then came Enterprise Grid in 2017, an org that is a parent to many workspaces, with cross-workspace channels stored on the org's shard. Then shared channels, now called Slack Connect, between separate companies; Slack's help centre says one channel can host up to 250 organisations. And by March 2024, Grid users had one unified view of all their workspaces."

You

"How fast must a message arrive, and can it ever be lost?"

Interviewer

"Slack's 2023 post says the system 'delivers messages across the world in 500ms'. Its 2020 database post adds that 'every message sent in Slack is persisted before it's sent across the real-time websocket stack and shown to other members of the channel'. On a normal day the message-send success rate is over 99.999%."

You

"Is search a feature or the product?"

Interviewer

"Slack's engineers point out that the name is a backronym: 'Searchable Log of all Communication and Knowledge'. Search runs on Solr; in 2026 they wrote that the search pipeline 'builds Solr search indexes from terabytes of data daily'."

Functional Requirements#

Functional requirements
  • Tenancy: workspaces; orgs of many workspaces with org-wide channels and policies; channels shared with other organisations, each side with its own name and privacy for the channel
  • Messages: post, edit, delete, react, reply in threads; mention a user, @here or @channel; one ordered history per channel
  • Real time: every connected client gets the messages and events of the channels it belongs to; typing indicators; presence for the users on screen
  • History and search: recent history on open; search messages and files with Recent and Relevant sorts, filtered to what the searcher can read
  • Notifications: mentions, DMs and keywords, shaped by per-user, per-channel and per-device preferences; badges and unread counts on every device
  • Background work: notifications, link unfurls, reminders, scheduled messages and indexing run as jobs after the send

Non-Functional Requirements#

Non-functional requirements
Scale (published and estimated)
  • ~30 million connected clients at the daily peak (estimated below; "tens of millions" published)
  • ~20,000 messages a second at the top-of-the-hour peak (estimated below)
Latency (design targets)
  • Post to delivery on other members' screens: p99 < 500 ms worldwide (Slack's published figure, taken as the target)
  • Search: p95 < 1 s; boot to first channel visible: p95 < 3 s on a warm cache
Correctness and isolation
  • An acknowledged message is never lost; message-send success > 99.999% on a normal day
  • No message, search result or notification ever reaches someone outside the channel's members
  • One tenant's load, or one failed server, must not take down others

Phase 2 · Stage 2 of 7 · 5 min budget · 4 min read

Phase 2: Back of the Envelope Estimation (5 minutes)#

You

"Slack's published anchors are old but solid: 12.5 million connected users in 2020, 'tens of millions of connected clients' and 16 million channels per channel server in 2023, more than a billion messages a week in 2019, 2.3 million database queries a second in 2020, and 1.4 billion jobs on the busiest days in 2017. Everything about today's message rate, events per message and recipients per message is my assumption, and I'll label it."

Your turn · checkpoint 1not answered

Before the numbers: what does a pandemic fortnight do to a sharded database?

Slack's simultaneously connected users went from 10 million on 10 March 2020 to 12.5 million on 25 March. Its database team saw query rates rise 50% in one week. How much growth is that per day? Now suppose every workspace lives entirely on one MySQL shard, and your largest customer's shard was already on the biggest machine you can buy. What do you do when that customer's load rises 50% in a week?

Think it through, or jot it down. Then compare.

One strong answer

  • Growth: 12.5 ÷ 10 = 1.25 in 15 days, about 1.5% a day compounded; the database saw 1.5× in 7 days, about 6% a day, because each user also sent more (Slack reported messages per user per day up about 20% between 1 February and 25 March).
  • The trap: a shard can only grow up to the largest host. If all of one customer's data must live on one shard, the only moves left are caching harder or shedding load. You can't add a second machine to one workspace.
  • The way out: shard by something finer than the workspace, such as the channel, so one customer's load spreads over many shards, and use tooling that can split a hot shard online. Slack wrote that in March 2020 it "scaled up one of our busiest keyspaces horizontally using Vitess's splitting workflows", and that without resharding "we would've been unable to scale at all for our largest customers".

Connections and Fan-Out#

WorksheetConnected clients and real-time delivery (assumptions labelled)
Value
Connected users at the daily peak (assumed; 12.5 million in March 2020) ~20 million
Clients per connected user (assumed: desktop plus phone for many) ~1.5 → ~30 million websockets
Websockets per gateway server (assumed) ~50,000 → ~600 gateway hosts, ~1,200 with headroom to absorb a drained region
Messages, 2019 (Slack: more than a billion a week) 1 billion ÷ 604,800 s ≈ ~1,650 a second on average
Messages today (assumed ~3× 2019) ~5,000 a second average; ~20,000 a second at the top-of-the-hour minute of a peak hour
Connected clients per message (assumed average) ~30 → ~600,000 client deliveries a second at the peak minute
Events per message (reactions, edits, read markers, members joining; assumed) ~4× → ~2.4 million event deliveries a second at the peak minute

Conclusion: the writes are modest; the deliveries are millions a second, and they cross regions. The design must send one copy per gateway server, not one per client, across the expensive hops.

Channel Servers#

WorksheetHow many channel servers? (published per-host figure; totals assumed)
  • Published: "At peak times, about 16 million channels are served per host", where a "channel" is any ID that events are addressed to: a user, a workspace, an org, a file, a huddle or a regular channel (Slack Engineering, 2023)
  • Assume each connected client subscribes to ~100 such IDs (its channels, DMs, its user, its workspaces), heavily shared within a company: ~400 million distinct IDs with a subscriber at the peak
  • Hosts: 400 million ÷ 16 million ≈ 25 channel servers at full load; ~50 at half load, so losing one moves only ~2% of channels
  • Replacement: Slack's ring managers bring a new channel server into service "in under 20 seconds"

Conclusion: a small, stateful, in-memory tier carries the whole platform's routing. Its failure unit must be small and its recovery fast.

WorksheetThe back end behind each send (published 2017–2020 figures; today assumed)
  • Database: 2.3 million queries a second in December 2020, of which 2 million were reads and 300,000 writes (87% reads), median 2 ms, p99 11 ms, with Memcached in front for the hottest data
  • Jobs: 1.4 billion on the busiest days in 2017 ÷ 86,400 s ≈ 16,000 a second on average, against a published peak of 33,000 a second: about 2× the average
  • Notifications: an @channel in an assumed 100,000-member announcement channel is 100,000 notification decisions; at ~1.5 devices for the members who are away, on the order of 100,000 pushes from one message. Slack's tracing team wrote that @here and @channel can push to "hundreds of thousands of users across multiple devices"

Conclusion: one message is one database write, one ordered fan-out and a handful of jobs. The jobs are where a single message turns into a hundred thousand units of work.

Key Metrics Summary#

Metric Value
Connected users (Slack, March 2020) 12.5 million
Connected clients (Slack, 2023 / assumed today) tens of millions / ~30 million
Messages (Slack, 2019 / assumed today) 1 billion+ a week / ~5,000 a second average, ~20,000 at peak
Delivery target (Slack, 2023) 500 ms worldwide
Channels per channel server (Slack, 2023) ~16 million at peak
Database (Slack, 2020) 2.3 million queries a second, p99 11 ms
Jobs (Slack, 2017) 1.4 billion on the busiest days, 33,000 a second at peak
Flannel edge cache (Slack, 2017) 4 million connections, 600,000 queries a second
Quick check3 questions · +5 XP each

Requirements and estimation

  1. Q1/3 Slack's published job peak is 33,000 a second against 1.4 billion jobs on the busiest days. Roughly how peaky is that?

    Answer & explanation

    Answer: B

    1.4 billion ÷ 86,400 s ≈ 16,000 a second; 33,000 is about twice that.

  2. Q2/3 Why can't a workspace-sharded database absorb a 50% jump in one big customer's load?

    Answer & explanation

    Answer: B

    Sharding by a finer key, like the channel, lets one customer's load spread over many shards.

  3. Q3/3 Slack's channel servers carry about 16 million "channels" per host. Why so many?

    Answer & explanation

    Answer: B

    The routing layer addresses everything as a channel ID, which keeps one mechanism for all events.

Phase 3 · Stage 3 of 7 · 10 min budget · 4 min read

Phase 3: High-Level Design (10 minutes)#

Your turn · checkpoint 2not answered

Sketch it first: Meera's @here at 9:58

Meera posts "@here settlement file from Kaveri Bank missing" in #inc-payments, which has 900 members across three of Paylane's workspaces. Which component accepts the message and decides she may use @here? Where is it stored before anyone sees it, and on which shard? Which component knows which servers have members of #inc-payments connected, and how does the message reach 900 people in Bengaluru, Pune and Singapore? What happens for the members whose laptops are closed, and what else does this one message set off?

Think it through, or jot it down. Then compare.

One strong answer

  • The webapp (Slack's PHP-turned-Hack monolith that hosts the APIs) authenticates her, checks she's a member of the channel and may notify @here, and writes the message.
  • The database stores it first: a MySQL table behind Vitess, sharded by channel ID, so the write goes to the shard that owns #inc-payments, not to a "Paylane shard".
  • The real-time stack routes it: an admin server hashes the channel ID to the one channel server that owns the channel; that server sends one copy to every gateway server, in every region, that has a client subscribed to the channel; each gateway server pushes it down its subscribed websockets.
  • Members who are away: @here notifies only active members, so the away ones get an unread badge, not a push, unless their own preferences ask for every message. The webapp enqueues a decision job, and workers decide, per member and per device, whether to notify and where.
  • Also set off: link unfurls, the search index update, unread counts and badges, all as background jobs, so none of them slow the send.
You

"I'll split it into four parts. A write path: the webapp, which owns every API, every permission check and every write, over Vitess with Memcached in front. A real-time path: gateway servers at the edge holding websockets and subscriptions, channel servers in the core that own channels by consistent hashing, and admin servers between the webapp and the channel servers. An edge cache, Flannel, that boots clients with little data and answers their queries close to them. And an async path: a durable job queue that turns one message into notifications, unfurls and index updates, plus search. Writes go in over HTTPS to the webapp; events come out over the websocket."

System Architecture#

DiagramSlack's four paths: write, real time, edge cache, async
%% title: Slack's four paths: write, real time, edge cache, async
flowchart TD
  subgraph L1["Layer 1 · Clients"]
    direction LR
    APP@{ icon: "sd:laptop", label: "Slack clients<br/>desktop, mobile, web" }
  end
  subgraph L2["Layer 2 · Edge regions"]
    direction LR
    ENV@{ icon: "sd:load-balancer", label: "Envoy<br/>TLS, websockets, AZ weights" }
    FL["Flannel<br/>edge cache, lazy boot"]:::service
    GS["Gateway servers<br/>websockets, subscriptions"]:::core
  end
  subgraph L3["Layer 3 · Core region"]
    direction LR
    WEB@{ icon: "sd:api", label: "Webapp · Hack<br/>every API and write" }
    AS["Admin servers<br/>stateless routers"]:::service
    CS["Channel servers<br/>consistent hash, in-memory"]:::hot
    PS["Presence servers<br/>users hashed"]:::service
  end
  subgraph L4["Layer 4 · State"]
    direction LR
    VT@{ icon: "tech:mysql", label: "Vitess · MySQL<br/>keyspaces by channel or user" }
    MC@{ icon: "sd:cache", label: "Memcached · Mcrouter<br/>Mcrib control plane" }
    CON@{ icon: "sd:network", label: "Consul<br/>discovery, hash rings" }
  end
  subgraph L5["Layer 5 · Async and search"]
    direction LR
    KF@{ icon: "tech:kafka", label: "Kafkagate · Kafka<br/>durable job buffer" }
    RQ@{ icon: "tech:redis", label: "JQRelay · Redis<br/>queues, worker pools" }
    SR@{ icon: "sd:search", label: "Search · Solr<br/>indexes, ranking" }
    PUSH@{ icon: "sd:bell", label: "Push service<br/>APNs, FCM" }
  end
  APP ==> ENV
  ENV ==>|"websocket"| GS
  ENV ==>|"HTTPS API"| WEB
  APP -.->|"boot, queries"| FL
  FL -.-> WEB
  WEB ==> VT
  WEB --> MC
  WEB ==>|"after the write"| AS ==> CS
  CS ==>|"one copy per gateway"| GS
  GS --> PS
  CON -.-> CS
  WEB ==>|"jobs"| KF ==> RQ
  RQ --> PUSH
  RQ --> SR
  class APP client
  class ENV,WEB edge
  class VT,CON store
  class MC cache
  class KF,RQ queue
  class SR service
  class PUSH external

KEY INSIGHTS:

  • The channel is the unit of routing, not the workspace. Every event is addressed to a channel ID (a conversation, a user, a workspace, an org, a file); the channel server that owns the ID orders and fans out its events, and one workspace's channels are spread over every channel server
  • Store, then send, then do the rest. A message is written to the database before any client sees it; everything that isn't the message itself (notifications, unfurls, indexing) is a job that can lag or retry without touching the send
  • Two hops of fan-out, both by subscription. Channel servers send one copy per subscribed gateway server; gateway servers send one copy per subscribed client. Nothing in the core knows about individual websockets

Data Flow: Meera's @here#

DiagramMeera's @here, from send to 900 members
%% title: Meera's @here, from send to 900 members
sequenceDiagram
  autonumber
  participant M as Meera's client
  participant E as Envoy
  participant W as Webapp
  participant V as Vitess
  participant A as Admin server
  participant C as Channel server
  participant G as Gateway servers
  participant O as Members' clients
  M->>E: POST message to #inc-payments, @here
  E->>W: route to the core region
  W->>W: auth, membership, may she notify @here
  W->>V: insert, routed by channel id
  V-->>W: stored
  W-->>M: ok, message id
  W->>A: message for channel C042
  A->>C: hash(C042), the owning channel server
  C->>G: one copy per subscribed gateway server
  G->>O: websocket push to subscribed clients
  Note over W,O: jobs follow: notifications, unfurls, index update

MEANWHILE, IN PARALLEL:

  • The webapp enqueues jobs through Kafkagate: notification decisions for the 900 members, an unfurl of the dashboard link in the message, and our search index update; workers pick them up from Redis within seconds
  • Kaveri Bank's engineer, typing in the shared channel, sends a typing event straight over her websocket to her gateway server, which routes it by channel ID to the channel server; it is never written to the database
Quick check2 questions · +5 XP each

High-level design

  1. Q1/2 Why is the message written to the database before the channel server sends it out?

    Answer & explanation

    Answer: B

    Store, then send. A client that misses the live copy can always fetch it from history.

  2. Q2/2 Which component knows which websockets are subscribed to a channel?

    Answer & explanation

    Answer: C

    Two hops of subscription keep the core free of per-client state.

Phase 4 · Stage 4 of 7 · 20 min budget · 24 min read

Phase 4: Deep Dives (20 minutes)#

Deep Dive 1: Workspaces, Orgs and Shared Channels: One Copy of Every Channel (Week 9 Concepts: Tenant Isolation; Week 1: Partitioning)#

You

"Slack began with the cleanest multi-tenant model there is: everything belongs to one workspace, and the workspace picks the database shard, the message server and the search shard. Then customers asked for three things that break it: one company with many workspaces, two companies in one channel, and one screen showing all of it. Each of those moves the tenant boundary from 'where the data lives' to 'who may see it'."

This builds on Week 9, Day 1 · Tenant Isolation, Week 1, Day 1 · Partitioning Deep Dive and Week 9, Day 3 · Data Residency.

Your turn · checkpoint 3not answered

#paylane-kaveribank

Paylane created #paylane-kaveribank and invited Kaveri Bank. In the original model each company's data lives on its own shard. Where should the channel's messages live: on both shards, or one? Paylane calls the channel #paylane-kaveribank and keeps it public inside Paylane; the bank wants to call it #kaveri-paylane-ops and keep it private on its side. How can one channel have two names and two privacy settings? And when a bank engineer opens Meera's profile, which rule decides whether they may see it at all?

Think it through, or jot it down. Then compare.

One strong answer

  • One copy. Writing every message, reaction and pin to both shards doubles storage, and two writes to two databases and two message servers can land in different orders, giving the two sides different histories. Keep one copy with the channel's owner and route both sides' reads and writes there.
  • Per-side views. Keep one channel row and add a row per workspace in a shared_channels table that points at the channel and overrides its name, topic and privacy for that side. The originating side reads the channel row; the other side reads its overrides.
  • Privacy is a flag, not a table. If "private" is encoded in the channel's ID or the table it lives in, one channel can't be private on one side and public on the other. Make it an explicit is_private field, per side.
  • Visibility follows membership. A bank engineer may see Meera's profile because they share a channel, and only as much of it as an external user may see. Without a shared channel, Meera doesn't exist for them.

What is published: in 2019 Slack described the original model, in which a new workspace "was assigned to a specific database shard, messaging server shard, and search service shard". With "more than a billion messages" a week, copying shared channels to both sides was rejected: each channel has "a single channels row" on the originating workspace's shard, with all its messages, reactions and pins, plus "two additional shared_channels rows: one row for each workspace" whose overrides let "each workspace ... set its own name, topic and purpose". Privacy moved from G-prefixed IDs in a separate groups table to an explicit is_private flag, and the conversations.* API replaced channels.* and groups.*. Enterprise Grid (2017) added an org above workspaces, with cross-workspace channels on the org's shard, so the backend queries "both the workspace shard and, if absent there, on the org shard". Some of Slack's largest customers "have thousands of workspaces" (2024). Unified Grid, completed in March 2024, shows Grid users every workspace at once; where an API still needs workspace context, the backend falls back to iterating over the user's "relevant" workspaces, capped at 50. A Slack Connect channel can host up to 250 organisations, and only the owning one can invite or remove others (Slack help centre).

DiagramResolving one channel for one user, across workspaces and companies
%% title: Resolving one channel for one user, across workspaces and companies
flowchart TD
  R["Request<br/>user, workspace, channel C"]:::service
  OWN@{ icon: "sd:database", label: "Channel row<br/>with the owner, one copy" }
  SIDE{"Same workspace<br/>as the owner?"}
  SC@{ icon: "sd:database", label: "shared_channels row<br/>this side's overrides" }
  VIEW["This side's view<br/>name, topic, is_private"]:::core
  MEM{"User a member,<br/>or allowed to join?"}
  OK["Serve the one history<br/>routed by channel id"]:::ok
  NO["Channel not found"]:::bad
  R ==> OWN ==> SIDE
  SIDE -->|"yes"| VIEW
  SIDE ==>|"no"| SC ==> VIEW
  VIEW ==> MEM
  MEM ==>|"yes"| OK
  MEM -.->|"no"| NO
  class OWN,SC store
  • The tenant is a policy boundary, not a storage boundary. Admins, retention, data residency and keys still belong to an organisation; where a channel's rows sit is a routing detail. Checking "same workspace?" as a proxy for "allowed?" is exactly the shortcut shared channels break, so every access check asks about membership.
  • One copy means one order. A shared channel has one history, one owner shard and one channel server, so both companies see messages in the same order. Two copies would need cross-database ordering that nobody wants to build.
  • Org-wide data is fetched once. Slack's 2024 post notes it changed the real-time stack "to remove the need to fan-out org-wide data to every workspace on the grid". An org-level object (an org-wide channel, an org admin's profile) is one channel ID, subscribed to once, not copied into each of thousands of workspaces.
  • Policy follows the organisation. Slack listed data retention and Enterprise Key Management as the hard remaining work for shared channels in 2019. With one copy, a design must say whose policy governs it; ours records the owning organisation on the channel and applies each side's retention and export rules to its own view.
pythonChannelView · 44 lines
# tenancy/shared_channels.py

"""
One copy of a shared channel (illustrative, after Slack's 2019 design). The channel
row and its history live with the originating side; every other workspace in the
channel has a shared_channels row that points at it and overrides name, topic and
privacy for that side. Membership, never workspace, decides access.
"""

from dataclasses import dataclass


@dataclass
class ChannelView:
    channel_id: str
    owner_workspace: str
    name: str
    is_private: bool


class NotFound(Exception):
    pass


def channel_view(db, workspace_id: str, channel_id: str) -> ChannelView:
    ch = db.channels.get(channel_id)                         # the single channels row
    if ch is None:
        raise NotFound(channel_id)
    if ch.workspace_id == workspace_id:
        return ChannelView(channel_id, ch.workspace_id, ch.name, ch.is_private)
    sc = db.shared_channels.get(workspace_id, channel_id)    # this side's pointer
    if sc is None:
        raise NotFound(channel_id)                           # same answer as "doesn't exist"
    return ChannelView(channel_id, ch.workspace_id,
                       sc.name or ch.name,
                       ch.is_private if sc.is_private is None else sc.is_private)


def read_history(db, user, workspace_id: str, channel_id: str, before_ts: str, limit: int = 50):
    view = channel_view(db, workspace_id, channel_id)
    if not db.members.contains(channel_id, user.id):
        if view.is_private or not db.can_join(user, view):
            raise NotFound(channel_id)
    return db.messages.latest(channel_id, before_ts, limit)  # routed by channel id, one copy

Deep Dive 2: Real Time: Channel Servers, Gateway Servers and the Edge Cache (Week 1 Concepts: Partitioning; Week 4: Multi-Tier Caching, Thundering Herd)#

You

"This is where Slack and Discord made opposite choices. Discord puts a whole server in one process, so one place orders everything, and then spends years splitting the fan-out of its biggest servers. Slack hashes each channel ID to a channel server, so one company's channels are spread over every channel server and no server holds a whole tenant. That fits a workplace: events belong to channels, not to the company. The other workplace problem is the morning: the whole company opens the app in the same half hour, so a boot must cost what the screen needs, not what the company holds."

This builds on Week 1, Day 1 · Partitioning Deep Dive, Week 4, Day 5 · Multi-Tier Caching and Week 4, Day 3 · Thundering Herd. For one process per community and its relays, see Bonus Problem 31 (Discord).

Your turn · checkpoint 4not answered

10:00:00, and a channel server dies

At 10:00:00 IST reminders fire across Paylane and a thousand other companies. At 10:00:04 one channel server, owning about 16 million channel IDs, dies. Which channels are affected: all of Paylane's, or a slice of everyone's? How do gateway servers find where those channels went, and how does a client end up with a message posted while the replacement was starting? Separately: between 9:30 and 10:00, 6,000 Paylane laptops boot with cold caches. If each boot downloads every user, channel and membership in the workspace, what does that cost, and what makes it cheap?

Think it through, or jot it down. Then compare.

One strong answer

  • A slice of everyone's. Channel IDs are hashed onto the ring, so one server owns a small fraction of every company's channels. Slack: "a small number of teams' channels are mapped to a CS".
  • The ring changes, not the hash. Ring managers (CHARMs) bring a replacement into service "in under 20 seconds" and publish the ring through Consul, where admin and channel servers read it; in our design, gateway servers resubscribe only the moved channels.
  • A delay, not a loss. The message was stored before it was sent. Our design stamps each channel's events with a sequence number; a client that sees a gap after a resubscribe fetches the missing messages from history.
  • Cheap boots: boot with the user, their channel list and the first channel's messages, and load everything else on demand from an edge cache that hashes clients by workspace (team affinity), so a company's shared data is fetched from the core once per region and served to everyone after that.

What is published: Slack's 2023 post describes four Java services. Channel servers "are stateful and in-memory, holding some amount of history of channels", each "mapped to a subset of channels based on consistent hashing", about 16 million per host at peak, where a channel ID can belong to "a user, team, enterprise, file, huddle, or a regular Slack channel". CHARMs manage the ring. Gateway servers "hold users' information and websocket channel subscriptions", run in "multiple geographical regions", and can be drained region by region. Admin servers are stateless, between the webapp and channel servers; presence servers have users hashed to them. A message goes webapp → admin server → channel server → "every GS across the world that is subscribed to that channel" → every subscribed client. Traffic peaks at 11 am and 2 pm local time, with spikes at the top of the hour from reminders and scheduled messages. Slack's 2017 Flannel post describes the problem before it: rtm.start sent "a full snapshot of a team's data", and teams of "tens of thousands of users" suffered slow boots and "reconnection storms". Flannel is an edge cache "deployed to our edge points-of-presence" that returns "a slimmed down version" of the startup data, answers queries such as Quick Switcher autocomplete, routes "users on the same team who are from the same networking region" to the same instance, and sends a mentioned user's profile to clients that lack it "just before sending the message". It served 4 million connections and 600,000 queries a second; lazy loading cut boot payloads 7× on a 1,500-user team and 44× on a 32,000-user team; moving presence to subscriptions cut presence events 5×.

DiagramOne message, two hops of fan-out, three regions (illustrative)
%% title: One message, two hops of fan-out, three regions (illustrative)
flowchart TD
  W@{ icon: "sd:api", label: "Webapp<br/>message stored" }
  A["Admin server<br/>stateless, reads the ring"]:::service
  RING@{ icon: "sd:network", label: "Consul<br/>ring from CHARMs" }
  C["Channel server 17<br/>owns C042, sequence 9,312"]:::hot
  G1["Gateway · region A<br/>412 subscribed clients"]:::core
  G2["Gateway · region B<br/>36 subscribed clients"]:::core
  G3["Gateway · region C<br/>3 subscribed clients"]:::core
  U@{ icon: "sd:users", label: "Clients<br/>one websocket each" }
  W ==> A
  RING -.-> A
  A ==>|"hash(C042)"| C
  C ==> G1
  C ==> G2
  C --> G3
  G1 ==> U
  G2 ==> U
  G3 --> U
  class W edge
  class RING store
  class U client
  • Hash the channel, not the tenant. A huge company's load lands on every channel server a little, instead of on one server a lot; a channel server failure is a 20-second delay for a sliver of everyone. The tenant boundary is enforced when subscriptions are built (only channels the user belongs to), not by where channels are placed.
  • One copy per gateway server. The channel server's cost per message is the number of subscribed gateway servers, a handful; each gateway sends to its own clients. In 2017 the message server still forwarded "a copy to Flannel for each connected user", and Slack wrote that it's "much more efficient to send only one copy to each Flannel server".
  • Order per channel, nowhere else. The owning channel server is the one place a channel's events pass through, so it can stamp a per-channel sequence. Nobody needs an order across channels, which is what lets channels sit on different servers.
  • Transient events take the short path. Slack's "transient events", such as typing, go client → gateway → channel server → gateways and are never stored, so our design drops them under load before any message.
  • Boot cost should track the screen, not the company. A slim boot is the user, their channel list with unread state, and one channel's messages with the users mentioned in them; everything else is a query answered at the edge. Team affinity means Paylane's 6,000 people share one cached copy per region, and on a cold morning the core sees only the first boot per workspace per region.
  • Push what's about to be needed. Sending a mentioned user's profile just ahead of the message saves every recipient a round trip at the same moment: a herd avoided rather than absorbed.
pythonh · 42 lines
# realtime/channel_server.py

"""
Channel IDs on a consistent-hash ring (illustrative). The owner stamps a per-channel
sequence and sends one copy per subscribed gateway server; gateways resubscribe only
the channels whose owner changed, and tell clients to fetch any gap from history.
"""

import bisect
import hashlib


def h(key: str) -> int:
    return int.from_bytes(hashlib.md5(key.encode()).digest()[:8], "big")


class Ring:
    def __init__(self, servers, vnodes=200):
        self.points = sorted((h(f"{s}#{i}"), s) for s in servers for i in range(vnodes))
        self.keys = [p for p, _ in self.points]

    def owner(self, channel_id: str) -> str:
        return self.points[bisect.bisect(self.keys, h(channel_id)) % len(self.points)][1]


class ChannelServer:
    def __init__(self):
        self.seq, self.subs = {}, {}          # channel_id -> last sequence / gateway ids

    def publish(self, channel_id, event, send):
        self.seq[channel_id] = self.seq.get(channel_id, 0) + 1
        event = {**event, "channel": channel_id, "seq": self.seq[channel_id]}
        for gw in self.subs.get(channel_id, ()):              # a handful, not thousands
            send(gw, event)


def on_ring_change(gateway, old: Ring, new: Ring, subscribe):
    for c, sockets in gateway.sockets.items():
        if new.owner(c) != old.owner(c):                      # only the dead server's arc moves
            subscribe(new.owner(c), c, gateway.id)
            for ws in sockets:
                ws.send({"type": "resync", "channel": c})     # clients fetch any gap from history
Quick check4 questions · +5 XP each

Tenancy and real time

  1. Q1/4 Why does Slack keep one copy of a shared channel instead of one per company?

    Answer & explanation

    Answer: B

    One channels row and its history live with the owner; each side gets a shared_channels row with its own name and privacy.

  2. Q2/4 A channel server owning 16 million channel IDs dies. Who notices?

    Answer & explanation

    Answer: B

    Hashing channels, not tenants, spreads a server's failure thinly.

  3. Q3/4 Why does Flannel hash clients by workspace rather than by user?

    Answer & explanation

    Answer: B

    Same-team, same-region users land on the same instance; reconnecting users are served from its cache.

  4. Q4/4 What makes Slack's routing different from Discord's?

    Answer & explanation

    Answer: B

    A community is one big room; a workplace is many medium channels.

Deep Dive 3: Vitess: From Workspace Shards to Keyspaces by Channel (Week 1 Concepts: Partitioning, Hot Keys; Week 4: Caching Patterns)#

You

"Slack's first database design was the obvious one for a multi-tenant app: every workspace on one MySQL shard, a lookup table from workspace to shard, and the app connecting to the right one. It's simple and fast to debug, and it fails in exactly the two ways a tenant-sharded system fails: the biggest tenant outgrows the biggest machine, and every product that crosses tenants has to query several shards. The fix is to shard each kind of data by the key its queries use, and let a routing layer hide that from the application."

This builds on Week 1, Day 1 · Partitioning Deep Dive, Week 1, Day 4 · Hot Keys and Skew and Week 4, Day 1 · Caching Patterns. The February 2022 incident below is the war story Slack's new cache manager worked too well.

Your turn · checkpoint 5not answered

Two questions about one membership table

Two queries run on every boot: "which channels is Meera in?" and "who is in this group DM?" The membership table is sharded by user ID. Which of the two is a single-shard lookup, and what does the other cost? It's normally cached by channel ID with a long TTL, because a group DM's members never change. What happens to the database when a large share of that cache empties at once at the daily peak? And how would you have designed the tables to avoid the question?

Think it through, or jot it down. Then compare.

One strong answer

  • By user, "Meera's channels" is one shard. "Members of this group DM" isn't: the members' rows are on many shards, so the router has to send the query to every shard (a scatter query) and merge the answers.
  • Cached, it's fine; the scatter runs once per group DM per TTL. Uncached at peak, nearly every boot scatters to every shard, the shards slow, the queries time out, and timed-out queries can't refill the cache. Database load rises faster than the miss rate: a feedback loop that holds the system down even after the trigger stops.
  • Design: keep membership in two layouts, by user and by channel, written together, and route each query to the layout whose key it has. On a miss, query only for the keys that missed, and allow immutable data to come from replicas.

What is published: in 2020 Slack described its original layout, data "partitioned and scaled horizontally by workspace id", each shard at least two MySQL hosts in different data centres, all taking writes, and its limits: large customers hit "the largest available hardware", left "a few hot spots", and Enterprise Grid and Slack Connect broke "the paradigm that all data for a team will be on the same database shard". From 2017 it moved to Vitess, rejecting NoSQL and NewSQL stores, table by table with double writes, a backfill and "a parallel double-read diffing system": 99% of query load by December 2020, 2.3 million queries a second, median 2 ms, p99 11 ms, "dozens of keyspaces". Slack set out to shard message data "by the unique id of the channel"; by 2024 "the messages table is now sharded by channel ID". In March 2020 query rates rose 50% in a week, and a busy keyspace was split online. On 22 February 2022 a keyspace of channel membership "sharded by user" was overloaded by a group-DM membership query that, uncached, had to "query every shard", while a Consul agent upgrade on 25% of the fleet restarted the agents on cache nodes, which dropped out of service discovery; the Mcrib control plane swapped in empty spares and flushed nodes that rejoined. Slack throttled boots, read "only the data that was missing from memcached", used replicas, and later moved the query to a table "sharded by channel".

DiagramOne query router, keyspaces sharded by what the queries ask for
%% title: One query router, keyspaces sharded by what the queries ask for
flowchart TD
  APP@{ icon: "sd:api", label: "Webapp and services<br/>one SQL interface" }
  MC@{ icon: "sd:cache", label: "Memcached via Mcrouter<br/>cache-aside, long TTLs" }
  VG["Vitess query router<br/>sharding key to shard"]:::core
  MSG@{ icon: "tech:mysql", label: "Messages keyspace<br/>sharded by channel id" }
  MBU@{ icon: "tech:mysql", label: "Membership by user<br/>my channels" }
  MBC@{ icon: "tech:mysql", label: "Membership by channel<br/>members of C" }
  SC["Scatter query<br/>every shard, merge"]:::bad
  APP --> MC
  APP ==> VG
  VG ==>|"channel id known"| MSG
  VG ==>|"user id known"| MBU
  VG ==>|"channel id known"| MBC
  VG -.->|"no sharding key"| SC
  class APP edge
  class MC cache
  class MSG,MBU,MBC store
  • Shard by the query's key. Messages by channel, so a channel's history is one shard and a big customer's channels spread over all of them. Membership twice, by user and by channel. Each table's key is chosen from its heaviest query, not from who the customer is.
  • Treat a scatter query as a smell. It's correct, and at low volume it's fine; its cost grows with the number of shards, so it gets worse as you scale out. Keep a list of scatter queries, and give every high-volume one a table with the right key.
  • A cache the database can't live without is capacity. If the database can only serve the boot load at a high hit rate, then anything that empties the cache (a node replaced, a flush, a restart wave) is a capacity loss. Change cache membership slowly, warm replacement nodes before they serve, and fetch only the missing keys on a miss.
  • Bound the feedback loop. When timeouts stop the cache refilling, more load won't fix it. Shed new work (boots) until queries succeed again, then restore it in small steps.
pythonmembers_of · 25 lines
# data/membership.py

"""
Reading group-DM membership through a cache (illustrative). Membership is written
twice, by user and by channel; reads use the by-channel table, fetch only what the
cache is missing, and may come from replicas because group-DM membership never changes.
"""

TTL_S = 7 * 24 * 3600            # immutable data: a long TTL


def members_of(cache, db, channel_ids: list) -> dict:
    keys = {c: f"gdm_members:{c}" for c in channel_ids}
    hit = cache.get_many(list(keys.values()))
    out = {c: hit[k] for c, k in keys.items() if k in hit}
    missing = [c for c in channel_ids if c not in out]
    if missing:                                              # only the misses, never "all again"
        rows = db.query(
            "SELECT channel_id, user_id FROM members_by_channel WHERE channel_id IN %s",
            missing, keyspace="membership_by_channel", replica_ok=True)   # one shard per channel
        for c in missing:
            out[c] = [r.user_id for r in rows if r.channel_id == c]
            cache.set(keys[c], out[c], ttl=TTL_S)
    return out
Quick check2 questions · +5 XP each

The database

  1. Q1/2 Under a workspace-sharded database, what limited Slack's largest customers?

    Answer & explanation

    Answer: B

    Vitess let Slack shard messages by channel, so one customer's load spreads over many shards.

  2. Q2/2 On 22 February 2022, why did a cache-hit-rate drop overload the database so badly?

    Answer & explanation

    Answer: B

    Load grew faster than the miss rate. Throttling boots, fetching only missing keys and reading replicas broke the loop.

Deep Dive 4: The Job Queue and Notifications: One Message, Thousands of Side Effects (Week 3 Concepts: Queue vs Stream, Backpressure; Week 6: Notification Flow)#

You

"Posting a message should cost one write and one fan-out. Everything else it causes (a push to each member who's away, a link preview, an index update, unread badges) is work that may take seconds, may fail, and may be thousands of units for one message. That work belongs on a queue. The queue then becomes the most important system nobody looks at: if it stops accepting jobs, sending messages stops too."

This builds on Week 3, Day 1 · Queue vs Stream, Week 3, Day 3 · Backpressure and Flow Control and Week 6, Day 2 · Core Notification Flow.

Your turn · checkpoint 6not answered

The queue fills up

Your job queue is Redis: the web app pushes each job onto a list, and workers move a job from the pending list to an in-flight list while they run it. At 10:00 a database slowdown makes jobs take five times longer. Enqueues continue at full rate. What happens to Redis memory, and what happens when it's full: to enqueues, and to dequeues? Why might the queue stay stuck even after the database recovers? What single change would protect the web app from the backlog, and what does it cost you? Then: of the 900 members of #inc-payments, which ones should Meera's @here buzz, and on which device?

Think it through, or jot it down. Then compare.

One strong answer

  • Memory fills. Jobs arrive faster than they leave, and an in-memory queue grows until Redis hits its memory limit. Then enqueues fail, and every operation that enqueues a job (posting a message among them) fails with it.
  • Stuck: if moving a job to the in-flight list needs free memory, a full Redis can't dequeue either. Workers can't drain it even after the database is fine, so recovery is manual.
  • Put a durable log in front. The web app writes jobs to Kafka, on disk with days of retention; a relay moves them from Kafka into Redis at a rate the workers can sustain. A backlog becomes lag in Kafka, not a full Redis. The cost: another hop, another system to run, and a choice about how many replicas must acknowledge a job before the send continues.
  • The notification decision is per member and per device. @here targets members who are active; for each, apply their preferences (channel muted? all messages, mentions only, or nothing? do-not-disturb hours?), then pick the device: a desktop the user is at gets a desktop notification, otherwise the phone gets a push. Members who are away and not mentioned get only an unread badge.

What is published: Slack's 2017 job queue post: the queue handles "every Slack message post, push notification, URL unfurl, calendar reminder, and billing calculation", 1.4 billion jobs on the busiest days at up to 33,000 a second. An earlier outage began with database contention that slowed jobs until Redis hit "its maximum configured memory limit"; because dequeuing "required a bit of free Redis memory", the queue "remained locked up". Slack put Kafka in front of Redis rather than replacing it: Kafkagate (Go) writes jobs to Kafka waiting only for the leader's acknowledgement, a "bias towards availability over consistency"; JQRelay (Go) holds a Consul lock per topic, advances offsets only after a job reaches Redis, re-enqueues failing jobs to Kafka, and obeys rate limits from Consul. The cluster: 16 brokers, 32 partitions per topic, replication 3, 2 days of retention; the rollout double-wrote in "shadow" mode and sent heartbeat canaries through all 1,600 partitions every minute. For notifications, a 2023 post shows the path "through the webapp ..., job queue, push service, and several third-party services"; a 2017 post, building a thin desktop client for background workspaces, moved the logic for "whether to notify a user about a message" to the server, which computes has_unreads and mention_count and sends a desktop_notification event over the websocket. Backend requests are traced at 1%, but each notification is its own trace at 100%, linked to the message by span links, since one @here or @channel would otherwise make "billions of spans"; support triaged notification tickets 30% faster. Slack's help centre: @here notifies only active members, neither mention notifies people whose notifications are paused or inside a thread, and in Enterprise orgs only owners and admins can use them in channels of 10,000+ members.

DiagramThe job path: a durable log in front of the working queue
%% title: The job path: a durable log in front of the working queue
flowchart TD
  WEB@{ icon: "sd:api", label: "Webapp<br/>message stored, jobs created" }
  KG["Kafkagate · Go<br/>HTTP in, same AZ, leader ack"]:::service
  K@{ icon: "tech:kafka", label: "Kafka<br/>16 brokers, 32 partitions, 2 days" }
  JR["JQRelay · Go<br/>one per topic, rate-limited"]:::core
  R@{ icon: "tech:redis", label: "Redis<br/>pending and in-flight lists" }
  WK@{ icon: "sd:worker", label: "Worker pools<br/>notify, unfurl, index, remind" }
  P@{ icon: "sd:bell", label: "Push service<br/>per device" }
  APN@{ icon: "tech:apple", label: "APNs<br/>iOS, macOS" }
  FCM@{ icon: "tech:firebase", label: "FCM<br/>Android" }
  WEB ==> KG ==> K
  K ==>|"offset advances after write"| JR
  JR ==> R ==> WK
  JR -.->|"job error, back to Kafka"| K
  WK ==> P
  P --> APN
  P --> FCM
  class WEB edge
  class K,R queue
  class WK service
  class P service
  class APN,FCM external
  • A log absorbs; a queue schedules. Kafka holds days of jobs on disk and never refuses a write because workers are slow; Redis holds only what workers can take soon, and does the dedupe, retry and in-flight tracking the application already relies on. Adding the log in front changed nothing for the code that enqueues or runs jobs: Slack's "minimum viable change".
  • The relay is the throttle. The rate from Kafka into Redis is a dial. When workers or the database are struggling, turn it down; the backlog waits safely in Kafka, and Redis memory stays flat. Slack also noted the next step: tying the relay's rate to Redis memory automatically.
  • Choose the acknowledgement per job type. Waiting only for the leader keeps a send fast but can lose a job if a broker dies before replicating; for a link unfurl that's acceptable. Our design waits for in-sync replicas on job types where a loss is visible (a reminder, a billing step) and keeps leader acks for the rest.
  • Separate the decision from the delivery. One job per message decides who to notify (preferences, DND, mute, which device); one job per device does the push. The fan-out job is cheap and fast; the pushes are many, slow and allowed to retry.
pythondecide · 34 lines
# notifications/decide.py

"""
Deciding who to notify for one message, and on which device (illustrative). Runs as
a job after the send. One decision job per message, one push job per device; each
notification gets its own id, used as its trace id and as the push's idempotency key.
"""

import uuid


def decide(msg, members, prefs, presence, enqueue):
    for m in members:
        if m.user_id == msg.author_id:
            continue
        p = prefs.get(m.user_id, msg.channel_id)          # per user, per channel
        mentioned = m.user_id in msg.user_mentions
        broadcast = (msg.here and presence.active(m.user_id)) or msg.channel_all
        if broadcast and msg.in_thread and not mentioned:
            continue                                      # @here and @channel don't notify in threads
        if p.muted and not mentioned:
            continue
        if not (mentioned or broadcast or p.level == "all" or msg.is_dm):
            continue
        if p.dnd_now() and not p.dnd_override_for(msg.author_id):
            continue
        nid = str(uuid.uuid4())                           # trace id for this one notification
        device = presence.foreground_device(m.user_id)    # desktop if she's at it, else phones
        if device == "desktop":
            enqueue("desktop_notification", user=m.user_id, msg=msg.id, nid=nid)
        else:
            for d in presence.push_devices(m.user_id):
                enqueue("push", user=m.user_id, device=d, msg=msg.id, nid=nid,
                        idempotency_key=f"{nid}:{d}")

Deep Dive 5: Search: A Searchable Log, Scoped to What Each Person Can Read (Week 7 Concepts: Indexing Pipeline, Query Processing and Relevance)#

You

"Web search has billions of shared documents and millions of people typing the same query. Workplace search is the opposite: every user sees a different set of documents, the queries almost never repeat, and the one unforgivable bug is showing a message from a private channel to someone outside it. So the index is partitioned by tenant, every query carries the searcher's permissions into the engine, and ranking has to learn from each user's own work graph instead of from crowds."

This builds on Week 7, Day 2 · Indexing Pipeline and Week 7, Day 3 · Query Processing and Relevance.

Your turn · checkpoint 7not answered

"settlement file timeout"

Meera's org has 14 workspaces and 40,000 channels; she can read 400 of them, plus #paylane-kaveribank, which Kaveri Bank's engineers can read too. How do you guarantee her results come only from channels she can read today, including one she was removed from yesterday? A message posted ten seconds ago must be findable; a ranking change must apply to years of history. How do you build the index so both are true? And her query has never been typed by anyone before: what can a ranking model learn from, if not from other people's clicks on the same query?

Think it through, or jot it down. Then compare.

One strong answer

  • Permissions go into the query. Partition the index by tenant (the org or workspace), then filter by the set of channel IDs the searcher can read, computed at query time from the membership tables and passed into the engine as a filter. Re-check the top results against membership before returning them. Removed yesterday means not in the set today.
  • Shared channels are indexed once, with the channel's owner, and searched from both sides through the same membership filter.
  • Two paths into the index (a lambda architecture): new messages are indexed within seconds as a job after the send; separately, a batch pipeline rebuilds the indexes from the full data, so a new field, a new language analyser or a ranking feature reaches every old message, and any drift in the real-time path is repaired.
  • Learn from the user, not the crowd: affinity to the author, how much she reads and writes in the channel, the message's age, reactions and pins. Train on pairs within one result list (the clicked result beat the one above it), since there are no repeated queries to learn from.

What is published: "Slack" is a backronym for "Searchable Log of all Communication and Knowledge". In 2017 Slack wrote that "Solr powers search at Slack", that each user "has access to a unique set of documents" and queries "are rarely repeated". Recent sorts matches newest first; Relevant, used "about 17% of the time", became two-stage: Solr retrieves by cheap features, then the application re-ranks with an SVM trained on a pairwise transform of clicks, oversampling lower positions because a result at position n was "30% more likely to be clicked" than at n+1. The re-ranker raised clicked searches 9% and clicks at position 1 27%; Top Results runs Relevant alongside every Recent search. A 2017 talk by a Slack data engineer described "Solr offline indexing at scale" with a lambda architecture; a 2019 Slack talk showed the offline pipeline, separate encrypted indexes for Enterprise Key Management customers, and queries tokenised in all 8 supported languages. In 2026 Slack wrote that the pipeline "builds Solr search indexes from terabytes of data daily". Enterprise search (2025) adds external sources without storing them, querying their search APIs live with the user's own OAuth read scopes.

DiagramTwo paths into the index, one permission-scoped query out
%% title: Two paths into the index, one permission-scoped query out
flowchart TD
  MSG@{ icon: "sd:api", label: "Message stored<br/>index job enqueued" }
  RT["Real-time indexer<br/>seconds behind"]:::service
  DW@{ icon: "sd:database", label: "Full data<br/>database and warehouse" }
  OFF@{ icon: "tech:spark", label: "Offline pipeline<br/>join, analyse, build daily" }
  IDX@{ icon: "sd:search", label: "Solr collections<br/>per tenant, EKM apart" }
  Q["Query service<br/>readable channel ids"]:::core
  RR["Re-ranker<br/>work-graph features"]:::hot
  U@{ icon: "sd:user", label: "Meera<br/>settlement file timeout" }
  MSG ==> RT ==> IDX
  DW ==> OFF ==>|"swap in rebuilt index"| IDX
  U ==> Q
  Q ==>|"filter by channel ids"| IDX
  IDX ==>|"top few hundred"| RR
  RR ==>|"re-check, Top Results"| U
  class MSG edge
  class DW store
  class OFF service
  class IDX store
  class U client
  • Filter inside the engine. Fetching results and then dropping unreadable ones breaks pagination and leaks timing; passing the readable channel IDs as a filter means the engine only ever scores permitted documents. A final membership check on the returned page catches anything that changed in the last few seconds.
  • Real time for freshness, batch for truth. The real-time path makes a new message findable in seconds; the batch rebuild re-derives every document from the source data, so a new analyser or feature applies to all history and any message the real-time path missed comes back. Both write the same schema, so swapping a rebuilt index in is invisible.
  • Retrieve cheap, rank rich. Solr scores thousands of matches by text match and a few stored fields; the re-ranker scores only the top few hundred with features that need the user's context (affinity to the author, the channel's priority for her, reactions, pins).
  • Federate what you mustn't store. External documents stay in their systems: search them live with the user's own credentials, so permissions and freshness are the source system's, not a copy's.
pythonsearch · 28 lines
# search/query.py

"""
A permission-scoped, two-stage search (illustrative). Readable channel ids go into
the engine as a filter; the engine returns a few hundred candidates by cheap
features; the re-ranker scores them with the searcher's work-graph features; the
returned page is re-checked against membership.
"""

CANDIDATES = 300


def search(user, text, membership, engine, graph, model, sort="recent", page=20):
    readable = membership.readable_channel_ids(user)          # computed now, not cached for long
    partitions = membership.index_partitions(readable)        # own tenant plus shared-channel owners
    hits = engine.query(text, partitions=partitions,
                        filter_channels=readable,
                        sort="date desc" if sort == "recent" else "score desc",
                        rows=CANDIDATES if sort == "relevant" else page)
    if sort == "relevant":
        feats = [[h.lucene_score, h.age_days,
                  graph.affinity(user.id, h.author_id),
                  graph.channel_priority(user.id, h.channel_id),
                  h.reactions, h.pinned, h.author_id == user.id] for h in hits]
        scores = model.score(feats)
        hits = [h for _, h in sorted(zip(scores, hits), key=lambda p: -p[0])]
    final = [h for h in hits[:page * 2] if membership.is_member(user.id, h.channel_id)]
    return final[:page]
Quick check3 questions · +5 XP each

Jobs, notifications and search

  1. Q1/3 Why did a full Redis queue stay stuck after the database recovered?

    Answer & explanation

    Answer: B

    Slack's queue "required a bit of free Redis memory in order to dequeue a job", so it locked up until manual intervention.

  2. Q2/3 Why does Slack trace each notification as its own trace?

    Answer & explanation

    Answer: B

    Span links tie each notification back to the message's trace.

  3. Q3/3 How should search guarantee Meera never sees a message from a private channel she isn't in?

    Answer & explanation

    Answer: C

    Filtering inside the engine keeps ranking and pagination honest; the re-check covers changes in the last few seconds.

Phase 5 · Stage 5 of 7 · 5 min budget · 2 min read

Phase 5: Scaling and Edge Cases (5 minutes)#

Interviewer

"Walk me through Meera's morning from the platform's side, then the cases that bite."

Meera's Monday#

The first working Monday of 2026, from the platform's side (illustrative)
  • 9:30 am IST · The office opens its laptops
    • Cold local caches everywhere; the first Paylane boot in the region fills the edge cache, and the next 5,999 are served from it with slim boots
  • 9:58 am · @here in #inc-payments
    • One write to the channel's shard; one channel server; a handful of gateway copies; one decision job, then notifications to the active members' desktops and phones
  • 10:00:00 am · The top of the hour
    • Reminders and scheduled messages across thousands of companies; jobs pile into Kafka and the relay feeds Redis at the rate the workers can take
  • 10:02 am · The shared channel
    • Kaveri Bank's engineer types and replies; one history on the owner's side, two names, both companies' clients subscribed to one channel ID
  • 10:05 am · The search
    • Meera's readable channels go into the query as a filter; last March's thread comes back as a Top Result

Edge Cases#

Edge cases
Edge case 1: The load balancer that forgot new servers

Problem: On 12 May 2020, after a morning incident had scaled the web tier up 75%, Slack ran more web servers than its HAProxy configuration had "slots" for. A sync program with a bug stopped updating HAProxy's backend list, which grew staler all day; at 4:45 pm Pacific, scale-down removed the older servers HAProxy still knew about, and requests failed (Slack Engineering, June 2020).

Solution: Slack did a rolling restart of HAProxy, then moved its ingress load balancing to Envoy, which receives endpoints dynamically from an xDS control plane (by 2023 all its edge load balancers ran Envoy). The general rule: any fixed-size table of capacity is a limit; alert on how full it is, and test the alert.

Edge case 2: One availability zone half-fails

Problem: On 30 June 2021 a faulty network link in one AWS availability zone degraded Slack for customers, because a request fans out to many backends spread across zones and every frontend saw some failures (Slack Engineering, August 2023).

Solution: Slack "siloed" its critical services so each zone talks only to itself, then built a drain: edge Envoys reweight per-zone clusters through Rotor, Slack's xDS control plane, to remove traffic from a zone within 5 minutes, in 1% steps, without relying on anything inside the bad zone.

Edge case 3: A company disconnects from a shared channel

Problem: Kaveri Bank leaves #paylane-kaveribank. Its engineers must lose access to the history at once, everywhere: live events, search and notifications.

Solution: The owning organisation keeps the channel (Slack's help centre: only the owner can continue using it after others are removed). Membership rows for the other side are removed in one transaction; that membership change is itself an event, so gateway servers drop the subscriptions; search filters by current membership, so the next query excludes it.

Quick check2 questions · +5 XP each

Scaling and edge cases

  1. Q1/2 On 12 May 2020, why did adding web servers not help Slack?

    Answer & explanation

    Answer: B

    A fixed number of slots became a hidden capacity limit; Envoy with a dynamic control plane removed it.

  2. Q2/2 What does "siloing" a service mean in Slack's cellular architecture?

    Answer & explanation

    Answer: B

    Each siloed service behaves like one virtual service per zone; the edge can then route around a bad zone.

Phase 6 · Stage 6 of 7 · 2 min

Phase 6: Monitoring and Operations#

You

"Five questions on a busy morning: are messages being sent and delivered, is any channel server or region unhealthy, is the job queue keeping up, are notifications arriving, and is the database inside its cache's protection? Plus one rule: the monitoring must not share the failure it's watching."

What is published: Slack's message-send success rate "is usually over 99.999%"; on 4 January 2021 an external monitor paged first, and Slack's own dashboards and alerts failed because they "were running in a different VPC from their backend databases", a dependency Slack said it would remove by running them in the same VPC. The job queue rollout sent heartbeat canaries through every Kafka partition each minute and alerted on their end-to-end timing. Notification traces flow to Elasticsearch, Grafana and the warehouse, where client engineers built dashboards and data scientists built funnels of notification open rates. Backend requests are traced at 1%. During the June 2021 incident, "nearly every graph we had aggregated by target AZ" showed the bad zone, which is what made a drain button the obvious fix.

Monitoring Dashboard#

Real-time dashboard — 10:01 am IST, a Monday (illustrative)
Messages and delivery
Metric Now
Message-send success 99.9993%
Messages posted 18,400/s
Post-to-delivery p99 (stored to client) 310 ms
Websockets connected 29.6M
Channel servers replaced (last hour) 1, back in 14 s
Jobs and notifications
  • Kafka lag, notification topic: 38 s; relay rate into Redis: 41,000/s of 45,000 allowed
  • Heartbeat canaries late: 0 / 1,600
  • Notifications decided → received on device, p95: 2.9 s; dropped in push providers: 0.04%
Data
  • Memcached hit rate, boot keys: 98.7%; scatter queries: 1,140/s (alert at 5,000)
  • Vitess p99: 12 ms; boots throttled: 0
  • Traffic by zone: 34% / 33% / 33%, no drains

SLOs for the Platform#

SLOs for sending, delivery, jobs and search
SLO 1: Messages send
  • Target: 99.999% of message posts succeed; zero acknowledged messages lost
SLO 2: Messages arrive
  • Target: 99% of messages on every subscribed connected client within 500 ms worldwide; channel-server failover under 20 s
SLO 3: Background work keeps up
  • Target: notification jobs started within 5 s of the send at p99; no Kafka topic lagging more than 5 minutes
SLO 4: Search is fresh and correct
  • Target: a new message searchable within 10 s at p99; zero results from channels the searcher can't read
Quick check2 questions · +5 XP each

Reading the dashboard

  1. Q1/2 The memcached hit rate for boot keys falls from 98.7% to 80% during a routine cache-node upgrade, and scatter queries jump tenfold. What do you do first?

    Answer & explanation

    Answer: B

    February 2022: once timeouts stop the cache refilling, the system stays down. Act before the loop closes.

  2. Q2/2 Kafka lag on the notification topic climbs to 10 minutes while Redis memory is flat. What's happening?

    Answer & explanation

    Answer: B

    That's the design working. Find the slow dependency, then raise the relay's rate.

Final challenge · Stage 7 of 7 · 3 min

Final Challenge: The Quietest Week, Then the Busiest Day#

The interviewer leans back. "This one is from Slack's own postmortem. Tell me what you'd have done at the time, and what you'd change."

Interviewer

"It's Monday 4 January 2021, the first working day of the year. Slack's services run in many VPCs linked by AWS Transit Gateways, which are managed by AWS and meant to scale on their own. Overnight, traffic goes from the quietest time of the year to one of the busiest, and clients come back with cold caches. One Transit Gateway doesn't scale fast enough and starts dropping packets. Your dashboards are down. Walk me through why Slack went down, and fix it."

The incident brief (Slack Engineering, February 2021)
  • Before 7:00 am PST: packet loss grows; an external monitor pages Slack; the dashboard and alerting service fails, because it sits in another VPC from its databases; at 6:57 am 99% of messages are still sent (normally over 99.999%)
  • 7:00 am: the top-of-the-hour mini-peak meets the degraded network; web servers wait on backends; the web tier saturates and Slack becomes unavailable
  • Automation: web-tier scaling follows CPU and Apache worker-thread use; waiting threads cut CPU, so it first scales down, ending engineers' SSH sessions; then, as threads fill, it tries to add 1,200 servers between 7:01 and 7:15
  • Provisioning: provision-service, on the same network, hits the Linux open files limit and an AWS quota; most new servers never finish setup, and the broken ones fill the auto-scaling groups to their maximum size
  • Recovery: provisioning works again around 8:15; by 9:15 Slack is degraded but up, helped by the load balancers' "panic mode"; AWS raises the gateway's capacity by hand, and by 10:40 am the network is normal
Your turn · checkpoint 8not answered

What do you do at 7:05, and what do you change by next January?

Decide what to stop and what to protect while the network is bad and the dashboards are gone, then what to change in capacity planning, autoscaling, provisioning, load balancing and monitoring so the next first-Monday is uneventful.

Think it through, or jot it down. Then compare.

One strong answer

  • Stop the automation that's making it worse. Disable scale-down while responders are on the machines (Slack did). Cap how fast unhealthy instances are replaced when they're unhealthy because a shared dependency is failing: replacing a server can't fix the network it can't reach.
  • Protect provisioning. It's on the critical path of every recovery; give it its own limits (open files, cloud quotas) sized for the biggest scale-up you might ever ask of it, and load-test it at that size. Slack committed to regular load tests of provision-service.
  • Keep serving with what you have. Load balancers that spread requests over all backends when most fail health checks ("panic mode" in Slack's load balancers; panic routing in Envoy, which Slack's websocket team said "was very helpful" that day) keep partial service while health checks are unreliable. Retries with backoff and circuit breakers keep the rest from piling on.
  • Plan for the known spike. The first Monday of the year is on the calendar. Ask the provider to pre-scale managed network components before it; Slack set itself "a Slack reminder, of course" to request "a preemptive upscaling of our TGWs at the end of the next holiday season". Treat the cold-cache boot as part of that spike: lazy boots and edge caches shrink it.
  • Scale on the right signal. CPU fell because threads were waiting, not because demand fell. Scale on worker saturation and request latency together, and never scale down while error rates are high.
  • The rule: "scales automatically" has a speed limit, for your systems and for your provider's. For a spike you can see coming, scale ahead of it.
Quick check3 questions · +5 XP each

Final challenge · Quest check

  1. Q1/3 Why did Slack's web tier first scale down as the network degraded?

    Answer & explanation

    Answer: B

    A waiting server looks idle by CPU. Scale on saturation and latency, not CPU alone.

  2. Q2/3 Why didn't adding 1,200 servers between 7:01 and 7:15 restore service?

    Answer & explanation

    Answer: B

    Provisioning is part of capacity. Load-test it at the largest scale-up you could ever request.

  3. Q3/3 What was Slack's fix for the next holiday season's Transit Gateways?

    Answer & explanation

    Answer: C

    The spike was predictable; the provider's autoscaling just wasn't fast enough for it.

Wrap-up · 7 min

Interview Conclusion#

Transcript
Interviewer

"Discord puts each server in one process. Why not one process per workspace here?"

You

"Because a workplace isn't one room. It's thousands of medium channels, and its tenants overlap: an org spans many workspaces, and a shared channel belongs to two companies at once, so there's no single tenant whose process could own it. Hashing each channel ID to a channel server gives every channel one owner and one order, spreads a big customer's load over the whole fleet, and makes a server failure a 20-second delay for a sliver of everyone instead of an outage for one customer. Nothing in a workplace needs one order across a whole company."

Interviewer

"Why did Slack stay on MySQL instead of moving to a NoSQL store?"

You

"Slack's own answer: thousands of distinct queries, some MySQL-specific, and years of operational practice for backups, durability and compliance. Vitess kept MySQL but took the sharding out of the application, so each table could be sharded by the key its queries use (messages by channel, membership by user and by channel) and resharded online. The thing to avoid was the workspace shard, not the relational model."

Interviewer

"The single most important decision?"

You

"Making the channel, not the workspace, the unit of routing and storage, while keeping the organisation as the unit of policy. That one change is what made shared channels, Enterprise Grid, Unified Grid and channel-sharded storage possible, and every access check, subscription and search filter asks the same question: is this person a member of this channel?"

Summary: Concepts Applied from 10-Week Course#

Concepts from the 10-week course in a workplace messaging platform
  • Week 1: Data at scale
    • Partitioning — channel IDs on a consistent-hash ring; Vitess keyspaces sharded by channel and by user
    • Hot keys and skew — big customers spread by channel; scatter queries as a scaling smell
  • Week 3: Messaging and async processing
    • Queue vs stream — Kafka as a durable log in front of a Redis working queue
    • Backpressure — a rate-limited relay; boot throttling under overload
  • Week 4: Caching
    • Multi-tier caching — an edge cache with team affinity; Memcached in front of Vitess
    • Thundering herd — slim boots, prefetch before a mention, cache churn in February 2022
  • Week 6: Notification platform
    • Server-side notification decisions, per device; one trace per notification
  • Week 7: Search
    • Real-time plus batch indexing; permission filters inside the engine; two-stage ranking
  • Week 9: Multi-tenancy and security
    • Tenant as policy, not storage; one copy of a shared channel; per-tenant encrypted indexes
  • Week 10: Production readiness
    • Capacity planning for a known spike; zone drains; monitoring off the blast path

Why Slack Matters#

Why this is a different kind of hard problem
The tenant boundary moved
  • Slack began with the textbook multi-tenant design, one workspace per shard, and then had to support orgs of thousands of workspaces and channels shared by up to 250 companies. The lesson is to keep the tenant as the unit of policy and stop using it as the unit of storage
The work is in what a message causes
  • Storing and delivering a message is cheap. The pushes, previews, indexing and badges it causes run on a job queue that must never refuse work, because when it does, sending messages fails too
Mornings are predictable, and still dangerous
  • Everyone opens Slack in the same half hour, and after a holiday with cold caches. Slack's incidents show the same pattern each time: a cache that is really capacity, automation that reacts to the wrong signal, and limits nobody knew about. For a spike you can see coming, scale ahead of it

"Route by the channel, govern by the organisation, and put everything a message causes on a queue that never says no."

Self-Assessment Checklist#

After studying this case study, you should be able to:

Tenancy and real time:

Tenancy and real time0/3 ready
  • Explain why shared channels broke workspace sharding, and design one copy of a channel with per-side views
  • Design channel servers on a consistent-hash ring with gateway servers as a second hop of fan-out
  • Contrast hashing channels across servers with one process per community, and say when each fits

Edge, storage and jobs:

Edge, storage and jobs0/3 ready
  • Shrink boot cost with lazy loading and an edge cache with team affinity
  • Choose sharding keys per table and spot scatter queries that a cache is hiding
  • Put a durable log in front of a working queue and use the relay as a throttle

Notifications, search and operations:

Notifications, search and operations0/3 ready
  • Decide notifications server-side per member and per device, and trace each notification
  • Build permission-scoped search with real-time and batch indexing and two-stage ranking
  • Walk through the January 2021 outage and fix it: pre-scale, scale on the right signal, keep monitoring off the blast path

Sources#

Slack Engineering (slack.engineering):

Slack company pages, help centre and talks:

Design note: the architecture beyond what Slack's posts describe, the code, thresholds, dashboards and every number labelled as an assumption or estimate are an illustrative design, not Slack's internals. Today's connected clients, message rates, events and recipients per message, websockets per gateway server, distinct channel IDs, job sizes, the notification rules in the code, search freshness and partitioning details, and all of Paylane's numbers are assumptions. Meera Iyer, Paylane and Kaveri Bank are fictional. Slack's published numbers come from posts and releases between 2016 and 2026 and describe the system at the time of each.

Further Reading#

Related systems to study:

From the war stories:

Foundational concepts (this course):

"Route by the channel, govern by the organisation, and put everything a message causes on a queue that never says no."

this lesson in shorts · 4

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Side quest 36 · Slack
Clear the questFinish Slack.Stages clear as you read them. Marking complete unlocks after ~9 more min of reading.
  • 0/7stages cleared

Follow-up drills

0 of 11 practised

You presented Slack. Now the interviewer pushes back. Answer each follow-up out loud or in the box, then reveal a strong answer.

Follow-up 1 / 11 · scale & numbers

“Slack published 12.5 million simultaneously connected users in 2020. What does real-time delivery cost at today's peak?”

Pushes on · writes versus deliveries

Revealing marks it practised.
design sheet · 7 diagrams · pdfRevise the whole design in ten minutesThe numbers, architecture, sequence, class and state diagrams, decisions and failure modes of this quest on a few printable pages.open the sheet →

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…