How to Design Enterprise Messaging like Slack
Building channels, threads, integrations, and real-time collaboration at 750K+ enterprise customer scale
Table of Contents
- Introduction
- Requirements
- Capacity Estimation
- Data Model
- API Design
- High-Level Architecture
- Message Send & Delivery Pipeline
- Channel Architecture
- Thread System
- Real-Time Messaging
- File Sharing & Preview
- Search System
- Bot & App Platform
- Workflow Builder & Automation
- Notification System
- Presence & Status
- Huddles & Voice/Video
- Enterprise Features
- Database Design & Sharding
- Caching Strategy
- Multi-Region Design
- Cost Estimation
- Interview Q&A
- Full C# Implementation
- Slack Connect & Cross-Workspace Collaboration
- Slack AI & Intelligence Features
- Conclusion
1. Introduction — Why Enterprise Messaging?
Slack has fundamentally transformed how enterprises communicate. With over 750,000 paying enterprise customers and serving organizations ranging from Fortune 500 companies to fast-growing startups, Slack processes billions of messages daily across millions of active channels. The platform supports over 2,400 integrations, has redefined the concept of channel-based communication, and has become the central nervous system for modern workplace collaboration.
Enterprise messaging is not simply "chat with channels." It is a sophisticated distributed system that must guarantee message ordering within channels, support real-time delivery with sub-second latency, handle file sharing and rich media, provide robust search across billions of historical messages, enforce enterprise compliance and security policies, integrate with thousands of third-party applications, and maintain five-nines availability across global regions.
In this comprehensive system design guide, we will architect an enterprise messaging platform from the ground up. We will cover every major component — from the message send pipeline to the search index, from WebSocket connections to multi-region failover, from bot platforms to enterprise SSO. Whether you are preparing for a senior+ system design interview or building such a system in production, this guide provides the depth and breadth you need.
- 750,000+ paying enterprise customers
- 65+ million daily active users
- Billions of messages sent per day
- 10+ billion searchable messages in the index
- 2,400+ app integrations
- Average of 90 minutes per user per day on the platform
2. Requirements
2.1 Functional Requirements
- Send and receive messages in channels (public, private), DMs, and group DMs
- Support threaded conversations within channels
- Rich message formatting (Markdown, code blocks, mentions, reactions, emoji)
- Message editing and deletion with audit trail
- Message pinning and bookmarking
- File and media attachments with inline previews
Channels & Workspaces:
- Create/join/leave public and private channels
- Multi-workspace organizations with shared channels
- Channel directories and categories
- Channel topic, description, and purpose
Real-Time:
- Sub-second message delivery to all connected clients
- Typing indicators
- Presence status (online, away, do not disturb)
- Read receipts and message viewed status
Search:
- Full-text search across messages, files, and people
- Advanced filters (date range, sender, channel, has:link, has:file)
- Search result ranking and relevance
Integrations & Bots:
- Bot users that can send messages and respond to events
- Incoming and outgoing webhooks
- App home tabs and message actions
- Workflow Builder for no-code automations
Enterprise:
- SAML-based SSO integration
- Data Loss Prevention (DLP) policies
- Message retention policies
- eDiscovery and legal hold
- Domain claiming and administration
2.2 Non-Functional Requirements
| Requirement | Target | Justification |
|---|---|---|
| Availability | 99.99% (52 min downtime/year) | Enterprise customers require near-zero downtime for critical communications |
| Latency (P99) | < 200ms for message send, < 500ms for delivery | Real-time feel is critical for adoption |
| Durability | Zero message loss | Business communications cannot lose messages |
| Consistency | Strong ordering per channel, eventual across channels | Messages within a channel must appear in order |
| Scalability | 10M+ concurrent WebSocket connections | Global enterprise scale |
| Security | End-to-end encryption option, SOC2, HIPAA | Enterprise compliance requirements |
3. Capacity Estimation
3.1 Traffic Estimation
- 65 million DAU, each user sends ~40 messages/day
- Average message size: 200 bytes (text) + 50 bytes metadata = 250 bytes
- Average user reads/receives ~200 messages/day (including channel messages)
- Each message is delivered to ~25 recipients on average (channel size)
Write Throughput:
- Messages written: 65M users × 40 msgs/day = 2.6 billion messages/day
- Write QPS: 2.6B / 86,400 ≈ 30,000 messages/second (peak: ~90,000 msg/s)
- Storage per day: 2.6B × 250 bytes ≈ 650 GB/day
- Storage per year: ~237 TB/year (raw), ~355 TB with indexes and replicas
Read Throughput:
- Feed reads: 65M users × 50 channel loads/day = 3.25B reads/day ≈ 37,600 reads/s
- Search queries: 65M × 5 searches/day = 325M/day ≈ 3,760 queries/s
- File downloads: ~100M/day ≈ 1,160/s
Connection Capacity:
- Peak concurrent connections: ~10M (assuming 15% concurrent rate)
- WebSocket messages: ~150M/min = 2.5M/s during peak
4. Data Model
4.1 Core Entities
4.2 Database Schema
-- Workspace: Top-level organizational unit
CREATE TABLE workspaces (
workspace_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
domain VARCHAR(255) UNIQUE NOT NULL,
plan VARCHAR(50) NOT NULL DEFAULT 'free',
owner_user_id UUID NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
settings JSONB DEFAULT '{}',
is_active BOOLEAN DEFAULT TRUE
);
-- Channel: Communication room within a workspace
CREATE TABLE channels (
channel_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(workspace_id),
name VARCHAR(255),
topic TEXT DEFAULT '',
purpose TEXT DEFAULT '',
channel_type VARCHAR(20) NOT NULL, -- public, private, dm, group_dm
created_by UUID NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
is_archived BOOLEAN DEFAULT FALSE,
member_count INT DEFAULT 0,
shared_channel BOOLEAN DEFAULT FALSE
);
-- User
CREATE TABLE users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(workspace_id),
display_name VARCHAR(255) NOT NULL,
real_name VARCHAR(255),
email VARCHAR(255) NOT NULL,
avatar_url TEXT,
title VARCHAR(255),
timezone VARCHAR(100),
status_text VARCHAR(255),
status_emoji VARCHAR(50),
presence_state VARCHAR(20) DEFAULT 'offline',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Message: The core entity
CREATE TABLE messages (
message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(channel_id),
user_id UUID NOT NULL REFERENCES users(user_id),
thread_id UUID, -- NULL for top-level messages
parent_id UUID, -- for threaded replies
content TEXT NOT NULL,
content_type VARCHAR(50) DEFAULT 'text',
edited_at TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE,
is_pinned BOOLEAN DEFAULT FALSE,
reactions JSONB DEFAULT '[]',
mentions JSONB DEFAULT '[]',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
) PARTITION BY RANGE (created_at);
-- Monthly partitions for messages
CREATE TABLE messages_2026_07 PARTITION OF messages
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
-- Channel membership
CREATE TABLE channel_members (
channel_id UUID NOT NULL REFERENCES channels(channel_id),
user_id UUID NOT NULL REFERENCES users(user_id),
role VARCHAR(20) DEFAULT 'member', -- member, admin, owner
joined_at TIMESTAMP DEFAULT NOW(),
last_read_at TIMESTAMP DEFAULT NOW(),
is_muted BOOLEAN DEFAULT FALSE,
notification_pref VARCHAR(20) DEFAULT 'all',
PRIMARY KEY (channel_id, user_id)
);
-- File attachments
CREATE TABLE file_attachments (
file_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID REFERENCES messages(message_id),
channel_id UUID NOT NULL,
uploaded_by UUID NOT NULL,
file_name VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100),
storage_url TEXT NOT NULL,
thumbnail_url TEXT,
preview_text TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Thread aggregation (denormalized for fast access)
CREATE TABLE threads (
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL,
parent_message_id UUID NOT NULL,
reply_count INT DEFAULT 0,
last_reply_at TIMESTAMP,
last_reply_by UUID,
participant_ids UUID[] DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
-- App integration
CREATE TABLE apps (
app_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID REFERENCES workspaces(workspace_id),
name VARCHAR(255) NOT NULL,
app_type VARCHAR(50) NOT NULL, -- bot, webhook, workflow
client_id VARCHAR(255) UNIQUE,
client_secret VARCHAR(255),
scopes TEXT[],
webhook_url TEXT,
event_subscriptions TEXT[],
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
4.3 Indexes for Hot Paths
CREATE INDEX idx_messages_channel_created ON messages(channel_id, created_at DESC);
CREATE INDEX idx_messages_thread ON messages(thread_id, created_at ASC);
CREATE INDEX idx_messages_user ON messages(user_id, created_at DESC);
CREATE INDEX idx_messages_mentions ON messages USING GIN(mentions);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
CREATE INDEX idx_channels_workspace_type ON channels(workspace_id, channel_type);
CREATE INDEX idx_users_workspace ON users(workspace_id);
CREATE INDEX idx_files_channel ON file_attachments(channel_id, created_at DESC);
5. API Design
5.1 Web API
The Slack Web API follows RESTful conventions with JSON payloads. All endpoints require authentication via Bearer token.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/chat.postMessage | Send a message to a channel |
| POST | /api/chat.update | Edit an existing message |
| POST | /api/chat.delete | Delete a message |
| GET | /api/conversations.history | Fetch channel message history |
| GET | /api/conversations.replies | Fetch thread replies |
| POST | /api/conversations.create | Create a new channel |
| POST | /api/conversations.join | Join a channel |
| GET | /api/search.messages | Search messages |
| POST | /api/files.upload | Upload a file |
| GET | /api/users.info | Get user details |
| GET | /api/presence.get | Get user presence |
5.2 Events API (Push Events)
The Events API delivers real-time events to your app via HTTP POST webhooks. Events include new messages, reactions, channel joins, presence changes, and more.
{
"token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
"team_id": "T061EG9R6",
"api_app_id": "A0FWB21ZV",
"event": {
"type": "message",
"user": "U023BECGF",
"text": "Hello, world!",
"ts": "1355517523.000006",
"channel": "C024BE91L",
"event_ts": "1355517523.000006",
"channel_type": "channel"
},
"type": "event_callback",
"event_id": "Ev0FWB20KK",
"created_at": 1355517523,
"authed_users": ["U023BECGF"]
}
5.3 Socket Mode
Socket Mode allows apps to receive events and interact with Slack APIs without exposing a public HTTP endpoint. The app opens a WebSocket connection to Slack's servers, receiving all events over the persistent connection. This is ideal for development environments, internal tools, and firewalled networks.
6. High-Level Architecture
Cloudflare / AWS Global Accelerator] CDN[CDN - Static Assets] WSS[WebSocket Gateway
Connection Manager] end subgraph "API Gateway" GW[API Gateway
Rate Limiting, Auth] REST[REST API Servers] WS[WebSocket Servers] EVENTS[Events API Service] end subgraph "Core Services" MSG[Message Service] CHAN[Channel Service] USER[User Service] THREAD[Thread Service] SEARCH[Search Service] FILE[File Service] NOTIFY[Notification Service] PRESENCE[Presence Service] BOT[Bot & App Service] WORKFLOW[Workflow Engine] end subgraph "Data Layer" PG[(PostgreSQL
Partitioned)] REDIS[(Redis Cluster
Cache + PubSub)] ES[(Elasticsearch
Search Index)] S3[(Object Storage
S3)] KAFKA[(Kafka
Event Bus)] end C1 & C2 & C3 --> CDN C1 & C2 & C3 --> LB C4 --> REST LB --> GW GW --> REST GW --> WSS WSS --> WS REST --> MSG & CHAN & USER & FILE & SEARCH WS --> MSG & PRESENCE EVENTS --> MSG & CHAN & BOT MSG --> KAFKA KAFKA --> SEARCH & NOTIFY & EVENTS MSG & CHAN & USER & THREAD --> PG MSG & CHAN & PRESENCE --> REDIS SEARCH --> ES FILE --> S3 BOT --> KAFKA WORKFLOW --> KAFKA
6.1 Key Design Principles
- Event-Driven Architecture: All state changes flow through Kafka, enabling decoupled consumers for search indexing, notifications, analytics, and compliance
- Separation of Read/Write: Write path goes through Message Service to Kafka; read path is served from materialized views in PostgreSQL and Redis cache
- WebSocket per Region: Each user maintains exactly one WebSocket connection to their nearest regional gateway
- Sharding by Workspace: Primary sharding key is workspace_id, enabling co-located data for workspace-local queries
- Idempotent Operations: All message sends accept a client-generated idempotency key to prevent duplicate messages
7. Message Send & Delivery Pipeline
The message send and delivery pipeline is the most critical path in the system. Every component must be optimized for low latency while maintaining strong guarantees.
7.1 Message Ordering Guarantee
Within a single channel, messages must be strictly ordered. We achieve this through a monotonically increasing sequence number per channel, assigned by the Message Service at write time.
// Sequence number generation using Redis
public async Task<long> GetNextSequenceAsync(string channelId)
{
var key = $"channel:{channelId}:seq";
return await _redis.StringIncrementAsync(key);
}
// Message with ordering
public class OrderedMessage
{
public Guid MessageId { get; set; }
public string ChannelId { get; set; }
public long SequenceNumber { get; set; } // Per-channel monotonically increasing
public string Content { get; set; }
public Guid UserId { get; set; }
public DateTime CreatedAt { get; set; }
public Guid? ThreadParentId { get; set; }
public string IdempotencyKey { get; set; }
}
7.2 Fan-Out Strategy
When a message is sent to a channel with 10,000 members, we must deliver it to all online members efficiently. We use a two-tier fan-out approach:
- Hot Path (Online Users): The message is published to Redis PubSub keyed by channel_id. Each WebSocket server subscribes to the channels its connected users care about. The message is pushed in real-time via WebSocket.
- Cold Path (Offline/Notification Users): The message event is consumed from Kafka by the Notification Service, which checks each member's notification preferences and sends push notifications (APNs/FCM) or email digests as appropriate.
8. Channel Architecture
8.1 Channel Types
| Channel Type | Visibility | Max Members | Description |
|---|---|---|---|
| Public | All workspace members | 500,000 | Open to all; anyone can join and read |
| Private | Invited members only | 500,000 | Restricted; requires invite or request |
| DM (Direct Message) | 1:1 only | 2 | Private conversation between two users |
| Group DM | Participants only | 9 | Small private group conversation |
| Shared Channel | Multiple workspaces | 500,000 | Cross-workspace collaboration |
8.2 Channel Membership and Permissions
Channel access is controlled through the channel_members table. For public channels, any workspace member can join. For private channels, explicit membership is required. The permission model uses roles: owner, admin, and member. Channel owners can manage settings, admins can manage members, and members can send messages.
8.3 Shared Channels (Multi-Workspace)
Shared channels allow organizations to collaborate across workspace boundaries. When a channel is shared, messages from external workspace members are replicated to both workspaces' data stores. This requires a conflict-free replicated data type (CRDT) approach for message ordering, since messages from different workspaces may arrive with slight clock skew. Each shared message carries its origin workspace ID and a hybrid logical timestamp for consistent ordering.
9. Thread System
Threads are one of Slack's most important features, allowing focused conversations within a channel without cluttering the main feed. Each thread is rooted in a parent message, and all replies are stored with a reference to the parent.
9.1 Thread Data Model
Thread replies store the thread_id (which is the parent message's ID) and are ordered by their sequence number. The threads table is a denormalized aggregation that tracks reply_count, last_reply_at, and participant_ids for fast display without querying all replies.
9.2 Thread Notifications
When someone replies to a thread, notifications are sent to:
- All participants in the thread (unless they muted it)
- People who were @mentioned in the parent message or earlier replies
- Workspace members who bookmarked the thread
The thread participant list is maintained incrementally — when a new reply arrives, the user_id is appended to the participant_ids array if not already present. This avoids a full table scan for notification targeting.
10. Real-Time Messaging (WebSocket, Long Polling)
Real-time delivery is the core UX differentiator. Slack uses WebSocket connections as the primary transport, with Server-Sent Events (SSE) and long polling as fallbacks.
10.1 Connection Management
Each WebSocket server maintains a mapping of user_id to connection. When a message arrives for a user, the Redis PubSub channel is checked, and the message is pushed through the appropriate WebSocket connection. Connection state is stored in Redis with a 60-second TTL, refreshed by heartbeats every 30 seconds.
public class WebSocketConnectionManager
{
private readonly ConcurrentDictionary<string, WebSocket> _connections = new();
private readonly ISubscriber _redisSub;
public async Task RegisterConnection(string userId, WebSocket socket, string[] channels)
{
_connections[userId] = socket;
await _redisSub.SubscribeAsync(
channels.Select(ch => (RedisChannel)$"channel:{ch}"),
async (channel, message) =>
{
if (_connections.TryGetValue(userId, out var ws) && ws.State == WebSocketState.Open)
{
var buffer = Encoding.UTF8.GetBytes(message);
await ws.SendAsync(new ArraySegment<byte>(buffer),
WebSocketMessageType.Text, true, CancellationToken.None);
}
});
// Register presence
await SetPresence(userId, "online");
}
public async Task HandleDisconnect(string userId)
{
_connections.TryRemove(userId, out _);
await SetPresence(userId, "offline");
}
public async Task<int> GetOnlineCount(string channelId)
{
var members = await GetChannelMembers(channelId);
return members.Count(m => _connections.ContainsKey(m));
}
}
10.2 Message Delivery Guarantees
Slack uses an "at-least-once" delivery model with deduplication. Each message carries a unique event_id and timestamp. The client maintains the timestamp of the last received message per channel and includes it in reconnection requests to fetch any missed messages. The server-side buffer retains messages for 5 minutes per user, after which they are purged and the client must do a full history reload.
11. File Sharing & Preview Generation
File sharing is a critical feature supporting images, videos, documents, audio, code snippets, and more. The system must handle files up to 1 GB per file.
11.1 File Processing Pipeline
Supported formats and processing:
- Images: Generate 3 thumbnails (128px, 360px, 800px), extract EXIF, detect faces for privacy blur
- Videos: Extract first frame as thumbnail, generate 10s preview clip, detect audio for waveform
- Documents (PDF, DOCX, PPTX): Convert first 3 pages to images, extract text for search indexing
- Audio (MP3, WAV, OGG): Generate waveform visualization, extract duration
- Code files: Syntax highlighting, extract first 20 lines for preview
- Spreadsheets: Render first sheet as image, extract cell data for search
12. Search System
Slack's search is one of its most valued features. Users need to find messages, files, people, and channels across potentially billions of records with sub-second response times.
Messages)] IDX --> ES2[(Elasticsearch
Files)] IDX --> ES3[(Elasticsearch
Users)] end subgraph "Read Path" QUERY[Search Query] --> QP[Query Parser] QP --> ES1 QP --> ES2 QP --> ES3 ES1 --> RANK[Result Ranker] ES2 --> RANK ES3 --> RANK RANK --> CACHE[Result Cache] CACHE --> RESP[Response] end
12.1 Search Features
| Feature | Syntax | Example |
|---|---|---|
| Basic text search | keyword | deployment plan |
| Phrase search | "phrase" | "quarterly report" |
| From user | from:user | from:@john |
| In channel | in:channel | in:#engineering |
| Date range | before/after | before:2026-07-01 |
| Has link | has:link | has:link |
| Has file | has:file | has:file |
| Has emoji | has:reaction | has:reaction |
| Boolean operators | AND, OR, NOT | deploy AND NOT staging |
12.2 Search Ranking Algorithm
Search results are ranked using a combination of signals:
- Text relevance: TF-IDF score from Elasticsearch with custom analyzers for code, URLs, and mentions
- Recency: More recent messages get a slight boost (decays over 90 days)
- Engagement: Messages with more reactions, replies, or links clicked rank higher
- User affinity: Messages from people you interact with frequently rank higher
- Channel relevance: Messages from channels you're active in get boosted
13. Bot & App Platform
The bot and app platform allows third-party developers to build integrations that run within Slack. This includes bot users that appear as team members, interactive messages with buttons and modals, incoming/outgoing webhooks, and the Bolt SDK for rapid development.
Python/JS/Java] API_TEST[API Tester] EVENTS_CFG[Event Subscriptions] OAUTH[OAuth 2.0 Flow] end
13.1 Bot Event Processing
When a bot receives an event (e.g., a message mentioning it), the Events API delivers the event to the app's configured Request URL. The app processes the event and can respond with a message, update, or interactive component. Bots must handle events idempotonously and within the 3-second timeout — for longer operations, acknowledge the event and respond asynchronously.
13.2 Rate Limiting for Apps
| Tier | Method | Limit | Window |
|---|---|---|---|
| Tier 1 | chat.postMessage | 1 msg/sec | Per channel |
| Tier 2 | conversations.history | 50 req/min | Per method |
| Tier 3 | users.list> | 50 req/min | Per method |
| Tier 4 | api.test | 1000 req/min | Per method |
14. Workflow Builder & Automation
Workflow Builder is a no-code tool that allows users to create automated workflows. It supports triggers (new message, emoji reaction, schedule, webhook) and actions (send message, create channel, collect form data, conditional logic).
Message/Emoji/Schedule/Webhook] --> WF_ENGINE[Workflow Engine] WF_ENGINE --> STEP1[Step 1: Collect Form] STEP1 --> STEP2[Step 2: Conditional Check] STEP2 -->|Yes| STEP3[Step 3: Send Message] STEP2 -->|No| STEP4[Step 4: Create Task] STEP3 --> STEP5[Step 5: Update Channel Topic] STEP4 --> STEP5
14.1 Workflow Execution Engine
The workflow engine is a state machine that processes workflow definitions stored as JSON. Each step is executed by a dedicated worker that reads the step configuration, invokes the appropriate action (API call, conditional evaluation, data transformation), and passes the output to the next step. Workflow state is persisted in PostgreSQL to support recovery and debugging. The engine supports parallel branches, loops (up to 100 iterations), and error handling with retry logic. Failed steps can trigger fallback actions or notify the workflow creator.
Workflow definitions are versioned — when a user modifies a workflow in the builder, a new version is published while in-flight executions of the previous version continue to completion. This ensures backward compatibility and prevents mid-execution failures from schema changes. The engine scales horizontally with Kafka-based work distribution, where each workflow trigger produces an event to the workflow-executions topic, and worker instances consume from the topic partition assigned to their consumer group.
public class WorkflowEngine
{
private readonly IWorkflowRepository _repository;
private readonly IActionExecutor _executor;
private readonly IMessageService _messageService;
public async Task ExecuteWorkflowAsync(string workflowId, Dictionary<string, object> triggerData)
{
var workflow = await _repository.GetWorkflowAsync(workflowId);
var state = new WorkflowState
{
WorkflowId = workflowId,
StartedAt = DateTime.UtcNow,
Variables = new Dictionary<string, object>(triggerData)
};
foreach (var step in workflow.Steps.OrderBy(s => s.Order))
{
state.CurrentStep = step.Id;
try
{
var result = await _executor.ExecuteStepAsync(step, state.Variables);
state.Variables[step.OutputKey] = result.Output;
if (step.IsConditional && !result.ConditionMet)
{
state.CurrentStep = step.FalseBranchId;
continue;
}
await _repository.SaveStateAsync(state);
}
catch (Exception ex)
{
state.Error = ex.Message;
state.Status = WorkflowStatus.Failed;
await _repository.SaveStateAsync(state);
throw;
}
}
state.Status = WorkflowStatus.Completed;
state.CompletedAt = DateTime.UtcNow;
await _repository.SaveStateAsync(state);
}
}
15. Notification System
The notification system must handle millions of notification dispatches per minute while respecting user preferences, DND schedules, and notification batching rules.
Preferences} PREF_CHECK -->|Channel Muted| DROP[Drop Notification] PREF_CHECK -->|DND Active| QUEUE[Buffer for Later] PREF_CHECK -->|Realtime| PUSH[Push Notification] PREF_CHECK -->|Batched| BATCH[Batch Buffer] BATCH -->|Every 15 min| DIGEST[Send Digest] PUSH --> APNS[Apple Push] PUSH --> FCM[Firebase Cloud] PUSH --> WS_PUSH[WebSocket Push] PUSH --> EMAIL[Email] QUEUE --> DND_RESOLVER[DND Resolver] DND_RESOLVER --> PUSH
15.1 Notification Batching
Slack batches notifications intelligently. If a user is mentioned in a channel, and 15 more messages arrive in that channel within 5 minutes, the user receives a single notification: "15 new messages in #channel, including a mention from @user" rather than 16 separate notifications. This is controlled by a per-channel timer that resets on each new notification and fires after 5 minutes of inactivity.
15.2 Do Not Disturb
DND can be set manually, on a schedule (e.g., 10 PM - 7 AM in user's timezone), or automatically based on calendar integration. During DND, push notifications and sounds are suppressed, but messages are still delivered and can be viewed. DND overrides are allowed for @everyone and @here mentions in channels the user has specifically allowed.
16. Presence & Status
Presence indicates whether a user is currently active, away, or in DND mode. Status is a user-configured message with an emoji (e.g., "Working from home 🏠").
16.1 Presence Detection
Presence is determined by:
- Active: WebSocket connection alive + client focus event in last 10 seconds
- Away: No focus events for 10 minutes (auto-away)
- Offline: No WebSocket connection for 60 seconds
Presence state is stored in Redis with a short TTL (30 seconds) that is refreshed by the client's focus events. This avoids expensive database writes for presence updates.
public class PresenceService
{
private readonly IDatabase _redis;
public async Task UpdatePresenceAsync(string userId, string state)
{
var key = $"presence:{userId}";
await _redis.StringSetAsync(key, state, TimeSpan.FromSeconds(60));
await _redis.Publisher.PublishAsync("presence-updates",
JsonSerializer.Serialize(new PresenceUpdate
{
UserId = userId,
State = state,
Timestamp = DateTime.UtcNow
}));
}
public async Task<string> GetPresenceAsync(string userId)
{
var state = await _redis.StringGetAsync($"presence:{userId}");
return state.HasValue ? state.ToString() : "offline";
}
}
17. Huddles & Voice/Video
Slack Huddles provide lightweight audio and video calls that can be started instantly in any channel or DM. Unlike scheduled video meetings, huddles are ephemeral and spontaneous — designed to replace quick hallway conversations and impromptu standups. A huddle can be started with a single click, and any channel member or thread participant can join instantly without a separate meeting link.
17.1 Architecture
- Signaling Server: Uses WebSocket for call signaling (join, leave, mute, screen share). The signaling server maintains call state and broadcasts participant changes to all connected clients in the huddle.
- Media Server (SFU): WebRTC Selective Forwarding Unit for efficient media routing. Unlike a full mesh (where every participant sends to every other participant), an SFU receives each participant's stream once and forwards it to all other participants, reducing upload bandwidth from O(n) to O(1) per client.
- TURN/STUN Servers: For NAT traversal and relay when peer-to-peer direct connection is not possible due to symmetric NATs or restrictive firewalls. TURN relay servers handle the actual media forwarding when direct P2P fails.
- Recording Service: Optional cloud recording for compliance (enterprise feature). The SFU forwards a copy of the mixed audio stream and individual video streams to the recording service, which stores them in S3 for later playback and transcription.
- Audio Processing: Real-time noise suppression, echo cancellation, and automatic gain control are applied client-side using WebRTC audio processing modules.
17.2 Huddle Lifecycle
When a user clicks the huddle icon in a channel, a call session is created with a unique ID and the creator becomes the initial participant. The signaling server broadcasts a "huddle.started" event to all channel members via the channel's WebSocket topic. Participants who join send a "join" signal, receive the list of current participants, and begin establishing WebRTC peer connections through the SFU. When the last participant leaves, the huddle session is terminated and a system message is posted to the channel summarizing the huddle duration and participants.
17.3 Thread Huddles and Screen Sharing
Huddles can be started within a thread context, linking the audio/video call to a specific discussion. Screen sharing uses the WebRTCgetDisplayMedia API with a custom cursor overlay for annotations. The shared screen is encoded as a high-priority video track with adaptive bitrate — the SFU dynamically adjusts quality based on each viewer's bandwidth. For enterprise compliance, screen shares in recorded huddles are stored alongside the video feeds.
18. Enterprise Features (SAML SSO, DLP, Compliance)
18.1 SAML SSO
Enterprise Grid workspaces support SAML 2.0 SSO integration with identity providers like Okta, Azure AD, and OneLogin. The SSO flow handles SP-initiated and IdP-initiated authentication, with Just-In-Time (JIT) provisioning for new users.
18.2 Data Loss Prevention (DLP)
| DLP Rule Type | Detection Method | Action |
|---|---|---|
| SSN / Credit Card | Regex pattern matching | Block + Alert admin |
| PII (email, phone) | Regex + NER model | Warn user + Log |
| Source code patterns | Entropy analysis + keywords | Alert admin |
| Confidential files | File label / keyword | Block upload |
| External sharing | Shared channel policy | Block + Review |
18.3 Compliance & eDiscovery
Enterprise customers require message retention policies (e.g., retain all messages for 7 years), legal holds (preserve messages for litigation), and eDiscovery export (produce messages in standard formats). All messages and file metadata are stored in append-only partitions, and deletion is logical (tombstone) rather than physical for compliance purposes.
19. Database Design & Sharding
19.1 Sharding Strategy
workspaces: A, F, K, P, U] WS2[Workspace B] --> SHARD2[Shard 2
workspaces: B, G, L, Q, V] WS3[Workspace C] --> SHARD3[Shard 3
workspaces: C, H, M, R, W] WS4[Workspace D] --> SHARD4[Shard 4
workspaces: D, I, N, S, X] WS5[Workspace E] --> SHARD5[Shard 5
workspaces: E, J, O, T, Y] end SHARD1 --> DB1[(Primary + 2 Replicas)] SHARD2 --> DB2[(Primary + 2 Replicas)] SHARD3 --> DB3[(Primary + 2 Replicas)] SHARD4 --> DB4[(Primary + 2 Replicas)] SHARD5 --> DB5[(Primary + 2 Replicas)]
Sharding is performed by workspace_id using consistent hashing. This ensures that all data for a workspace resides on the same shard, enabling efficient cross-table joins within a workspace context. Each shard runs a PostgreSQL primary with synchronous replication to 2 read replicas, providing high availability and read scaling.
19.2 Partitioning Strategy for Messages
The messages table is first sharded by workspace_id (via the shard), then partitioned by month using PostgreSQL range partitioning. Old partitions can be moved to cheaper storage (e.g., S3 with Parquet format) for long-term retention, while recent partitions remain on SSD for fast access.
19.3 Cross-Shard Queries
Some queries span multiple shards — for example, global search across all workspaces. These queries are handled by the Search Service, which maintains a separate Elasticsearch index that aggregates data from all shards via Kafka change data capture (CDC).
20. Caching Strategy
20.1 Cache Layers
| Cache | Technology | What's Cached | TTL | Invalidation |
|---|---|---|---|---|
| L1: Client | Local memory | Recent messages, user profiles | 5 min | WebSocket push |
| L2: Edge | Redis (in-memory) | Channel state, presence, session | 30-60s | PubSub |
| L3: Application | Redis Cluster | Hot channel messages, user data | 5-15 min | TTL + event-driven |
| L4: CDN | Cloudflare | Static assets, file previews | 1-24 hours | Cache busting |
20.2 Cache Warming
When a user opens a channel for the first time in a session, the client requests the last 100 messages. These are fetched from PostgreSQL, cached in Redis with a 15-minute TTL, and returned to the client. Subsequent loads hit the Redis cache. For large channels, we pre-warm the cache during off-peak hours.
20.3 Redis PubSub for Real-Time
Redis PubSub is the backbone of real-time delivery. When a message is written, it is immediately published to the channel's Redis PubSub topic. All WebSocket server instances subscribe to the channels their connected users care about. This provides fan-out with O(1) latency per subscriber, critical for the sub-second delivery requirement.
21. Multi-Region Design
Primary)] RD_US[(Redis)] ES_US[(Elasticsearch)] end subgraph "EU-WEST-1 (Secondary)" LB_EU[Load Balancer] API_EU[API Servers] DB_EU[(PostgreSQL
Read Replica)] RD_EU[(Redis)] ES_EU[(Elasticsearch)] end subgraph "AP-SOUTHEAST-1 (Secondary)" LB_AP[Load Balancer] API_AP[API Servers] DB_AP[(PostgreSQL
Read Replica)] RD_AP[(Redis)] ES_AP[(Elasticsearch)] end DB_US -->|Async Replication| DB_EU DB_US -->|Async Replication| DB_AP ES_US -->|Cross-Cluster Replication| ES_EU ES_US -->|Cross-Cluster Replication| ES_AP RD_US -->|Redis CRDT Replication| RD_EU RD_US -->|Redis CRDT Replication| RD_AP
21.1 Region Affinity
Each workspace is assigned a primary region based on the creator's location. All writes go to the primary region. Read replicas in other regions serve read requests with slightly higher latency. WebSocket connections are always routed to the nearest region.
21.2 Failover
If the primary region fails, DNS-based routing shifts traffic to the nearest healthy region. After failover, the read replica is promoted to primary. In-flight writes that were replicated before the failure are preserved; writes that were in the replication lag window may need client retry. The system targets an RPO (Recovery Point Objective) of under 5 seconds and RTO (Recovery Time Objective) of under 60 seconds.
22. Cost Estimation
22.1 Infrastructure Costs (Monthly)
| Component | Spec | Quantity | Monthly Cost |
|---|---|---|---|
| API/WebSocket Servers | c6i.2xlarge (8 vCPU, 16 GB) | 100 | $35,000 |
| PostgreSQL (Primary per shard) | r6i.4xlarge (16 vCPU, 128 GB) | 20 | $30,000 |
| PostgreSQL (Read Replicas) | r6i.2xlarge (8 vCPU, 64 GB) | 40 | $30,000 |
| Redis Cluster | r6i.xlarge nodes | 50 | $18,000 |
| Elasticsearch | r6i.2xlarge data nodes | 30 | $25,000 |
| Kafka (MSK) | kafka.m5.2xlarge | 20 | $15,000 |
| S3 Storage | ~500 TB total | - | $12,000 |
| CDN (Cloudflare Enterprise) | Bandwidth + WAF | - | $20,000 |
| Global Load Balancer | AWS Global Accelerator | 3 | $5,000 |
| Push Notification (APNs/FCM) | ~500M push/month | - | $3,000 |
| Monitoring & Observability | Datadog / PagerDuty | - | $15,000 |
| Total | ~$208,000/month |
22.2 Cost Per User
With 65 million DAU, the infrastructure cost per user per month is approximately $3.20. At an average revenue of $8.67 per user/month (Blended Pro + Enterprise Grid pricing), this yields a gross margin of ~63%, which is healthy for a SaaS business at this scale.
23. Interview Q&A
We use a per-channel monotonically increasing sequence number, assigned by the Message Service at write time using Redis INCR on the channel's sequence key. Each message is stored with (channel_id, sequence_number), and reads are always ordered by sequence_number. This guarantees strict ordering within a channel without requiring distributed locks.
Each client sends an idempotency_key with every message. The Message Service checks this key against a Redis deduplication cache (TTL 5 minutes) before writing. If a duplicate is detected, the existing message_id is returned without creating a new message. For WebSocket delivery, each push message carries an event_id; the client deduplicates locally.
We use a hybrid approach: (1) For online users connected via WebSocket, we publish to Redis PubSub per channel, and each WebSocket server pushes to its connected subscribers. (2) For offline users, we enqueue notifications via Kafka for batch processing. We never directly loop through 100K members — Redis PubSub handles the fan-out efficiently.
We use Elasticsearch with time-based indices (monthly). Messages are streamed from Kafka to search indexers, which batch-index into Elasticsearch. We use custom analyzers for code, URLs, and mentions. Results are cached in Redis for frequent queries. For ranking, we combine TF-IDF with engagement signals (reactions, replies, recency). We shard the index by workspace for data isolation and query performance.
We use synchronous replication to one standby and async replication to the DR region. On primary failure, the synchronous standby is promoted (RTO ~10s). DNS failover routes traffic to the DR region. Writes in-flight during the replication lag window may need client retry, but the idempotency mechanism prevents duplicates. The system targets RPO < 5 seconds.
We enforce tiered rate limits per app (e.g., 1 msg/sec for chat.postMessage per channel). Rate limits are tracked using a sliding window counter in Redis. Apps that exceed limits receive 429 responses with a Retry-After header. For persistent abusers, we suspend the app. Additionally, workspace admins can view app usage metrics and revoke access.
When a message is edited, the original content is preserved in a separate message_history table. The messages table stores only the current content with an updated_at timestamp. The message_history table retains all previous versions with their edit timestamps and editor user_id. This provides a complete audit trail for compliance without slowing down reads.
Thread replies are stored with thread_id = parent_message_id and are never included in the main channel feed. The main channel shows only top-level messages and a reply_count indicator. The Thread Service handles thread-specific queries (conversations.replies). We maintain a denormalized threads table with reply_count and last_reply_at for fast display without scanning all replies.
Messages in shared channels are replicated to both workspaces' data stores. Each message carries its origin workspace_id. We use hybrid logical timestamps (HLC) for consistent ordering across workspaces despite clock skew. The channel membership table tracks which workspaces have access, and permission checks are workspace-aware.
We preload the last 100 messages of the 5 most recently accessed channels on app launch. When switching channels, the client first renders from local cache (IndexedDB on web, SQLite on mobile), then fetches from the server if the cache is stale (> 5 minutes). We use optimistic updates and a skeleton UI to make switching feel instant. Server responses update the cache for next time.
We use presigned S3 URLs for direct client-to-S3 upload, bypassing our API servers for the actual data transfer. The client first requests a presigned URL from our Upload API, uploads directly to S3, then notifies our API of completion. S3 triggers a Lambda for preview generation (thumbnails, metadata extraction). This avoids loading large files through our application servers.
Presence state is stored in Redis with a 60-second TTL, refreshed every 30 seconds by client focus events. We never write presence to PostgreSQL — it's ephemeral. When a user's presence is queried, we check Redis first; if the key exists, the user is online; if not, they're offline. This is O(1) per lookup and avoids any database overhead.
24. Full C# Implementation
Below is a complete C# implementation of the core messaging system, including the Message Service, Channel Service, WebSocket Connection Manager, Search Service, and supporting infrastructure. This implementation covers 300+ lines and demonstrates production patterns.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace EnterpriseMessaging.Core
{
// ============================================================
// Domain Models
// ============================================================
public enum ChannelType { Public, Private, DirectMessage, GroupDM, Shared }
public enum UserRole { Guest, Member, Admin, Owner }
public enum PresenceState { Online, Away, Offline, DoNotDisturb }
public enum MessageContentType { Text, Code, File, System, Bot }
public class Workspace
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public string Domain { get; set; } = string.Empty;
public string Plan { get; set; } = "free";
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class Channel
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid WorkspaceId { get; set; }
public string? Name { get; set; }
public string Topic { get; set; } = string.Empty;
public string Purpose { get; set; } = string.Empty;
public ChannelType Type { get; set; }
public Guid CreatedBy { get; set; }
public bool IsArchived { get; set; }
public int MemberCount { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class User
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid WorkspaceId { get; set; }
public string DisplayName { get; set; } = string.Empty;
public string RealName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string? AvatarUrl { get; set; }
public PresenceState Presence { get; set; } = PresenceState.Offline;
public string StatusText { get; set; } = string.Empty;
public string StatusEmoji { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class Message
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ChannelId { get; set; }
public Guid UserId { get; set; }
public Guid? ThreadId { get; set; }
public string Content { get; set; } = string.Empty;
public MessageContentType ContentType { get; set; } = MessageContentType.Text;
public long SequenceNumber { get; set; }
public bool IsDeleted { get; set; }
public bool IsEdited { get; set; }
public bool IsPinned { get; set; }
public string? IdempotencyKey { get; set; }
public List<Reaction> Reactions { get; set; } = new();
public List<Guid> Mentions { get; set; } = new();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? EditedAt { get; set; }
}
public class Reaction
{
public string Emoji { get; set; } = string.Empty;
public List<Guid> UserIds { get; set; } = new();
}
public class Thread
{
public Guid Id { get; set; }
public Guid ChannelId { get; set; }
public Guid ParentMessageId { get; set; }
public int ReplyCount { get; set; }
public DateTime? LastReplyAt { get; set; }
public Guid? LastReplyBy { get; set; }
public HashSet<Guid> ParticipantIds { get; set; } = new();
}
public class ChannelMember
{
public Guid ChannelId { get; set; }
public Guid UserId { get; set; }
public UserRole Role { get; set; } = UserRole.Member;
public bool IsMuted { get; set; }
public string NotificationPref { get; set; } = "all";
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
public DateTime LastReadAt { get; set; } = DateTime.UtcNow;
}
public class FileAttachment
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid MessageId { get; set; }
public Guid ChannelId { get; set; }
public Guid UploadedBy { get; set; }
public string FileName { get; set; } = string.Empty;
public long FileSize { get; set; }
public string MimeType { get; set; } = string.Empty;
public string StorageUrl { get; set; } = string.Empty;
public string? ThumbnailUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// ============================================================
// Event Models for Kafka / Inter-Service Communication
// ============================================================
public class MessageEvent
{
public string EventType { get; set; } = "message.sent";
public Guid MessageId { get; set; }
public Guid ChannelId { get; set; }
public Guid WorkspaceId { get; set; }
public Guid UserId { get; set; }
public string Content { get; set; } = string.Empty;
public long SequenceNumber { get; set; }
public DateTime Timestamp { get; set; }
public Guid? ThreadId { get; set; }
public List<Guid> Mentions { get; set; } = new();
}
public class PresenceUpdate
{
public Guid UserId { get; set; }
public PresenceState State { get; set; }
public DateTime Timestamp { get; set; }
}
// ============================================================
// Interfaces
// ============================================================
public interface IMessageRepository
{
Task<Message> SaveMessageAsync(Message message);
Task<List<Message>> GetChannelHistoryAsync(Guid channelId, int limit = 100, DateTime? before = null);
Task<List<Message>> GetThreadRepliesAsync(Guid threadId, int limit = 100);
Task<Message?> GetMessageAsync(Guid messageId);
Task<bool> CheckIdempotencyKeyAsync(string key);
}
public interface IChannelRepository
{
Task<Channel> CreateChannelAsync(Channel channel);
Task<Channel?> GetChannelAsync(Guid channelId);
Task<List<Channel>> GetUserChannelsAsync(Guid userId);
Task<bool> IsMemberAsync(Guid channelId, Guid userId);
Task<ChannelMember> AddMemberAsync(ChannelMember member);
Task<List<Guid>> GetChannelMemberIdsAsync(Guid channelId);
}
public interface IThreadRepository
{
Task<Thread> GetOrCreateThreadAsync(Guid channelId, Guid parentMessageId);
Task UpdateThreadAsync(Thread thread);
Task<Thread?> GetThreadAsync(Guid threadId);
}
public interface ISearchService
{
Task IndexMessageAsync(Message message, Channel channel, User user);
Task<List<SearchResult>> SearchAsync(string query, Guid workspaceId, SearchFilters? filters = null);
}
public interface INotificationService
{
Task DispatchNotificationAsync(MessageEvent messageEvent, List<Guid> recipientIds);
}
// ============================================================
// Search Models
// ============================================================
public class SearchResult
{
public Guid MessageId { get; set; }
public string Content { get; set; } = string.Empty;
public Guid ChannelId { get; set; }
public string ChannelName { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public double Score { get; set; }
public DateTime CreatedAt { get; set; }
}
public class SearchFilters
{
public Guid? UserId { get; set; }
public Guid? ChannelId { get; set; }
public DateTime? Before { get; set; }
public DateTime? After { get; set; }
public bool? HasFile { get; set; }
public bool? HasLink { get; set; }
}
// ============================================================
// Message Service - Core Business Logic
// ============================================================
public class MessageService
{
private readonly IMessageRepository _messageRepo;
private readonly IChannelRepository _channelRepo;
private readonly IThreadRepository _threadRepo;
private readonly IConnectionMultiplexer _redis;
private readonly ISearchService _searchService;
private readonly INotificationService _notificationService;
private readonly ILogger<MessageService> _logger;
public MessageService(
IMessageRepository messageRepo,
IChannelRepository channelRepo,
IThreadRepository threadRepo,
IConnectionMultiplexer redis,
ISearchService searchService,
INotificationService notificationService,
ILogger<MessageService> logger)
{
_messageRepo = messageRepo;
_channelRepo = channelRepo;
_threadRepo = threadRepo;
_redis = redis;
_searchService = searchService;
_notificationService = notificationService;
_logger = logger;
}
public async Task<Message> SendMessageAsync(
Guid channelId, Guid userId, string content,
MessageContentType contentType = MessageContentType.Text,
string? idempotencyKey = null, Guid? threadId = null,
List<Guid>? mentions = null)
{
// Step 1: Idempotency check
if (!string.IsNullOrEmpty(idempotencyKey))
{
var isDuplicate = await _messageRepo.CheckIdempotencyKeyAsync(idempotencyKey);
if (isDuplicate)
{
_logger.LogWarning("Duplicate message detected for key {Key}", idempotencyKey);
throw new DuplicateMessageException(idempotencyKey);
}
}
// Step 2: Validate channel membership
var isMember = await _channelRepo.IsMemberAsync(channelId, userId);
if (!isMember)
{
throw new UnauthorizedAccessException(
$"User {userId} is not a member of channel {channelId}");
}
// Step 3: Get channel info for workspace context
var channel = await _channelRepo.GetChannelAsync(channelId)
?? throw new ChannelNotFoundException(channelId);
// Step 4: Assign sequence number
var db = _redis.GetDatabase();
var seqKey = $"channel:{channelId}:seq";
var sequenceNumber = await db.StringIncrementAsync(seqKey);
// Step 5: Create message
var message = new Message
{
ChannelId = channelId,
UserId = userId,
Content = content,
ContentType = contentType,
SequenceNumber = sequenceNumber,
IdempotencyKey = idempotencyKey,
ThreadId = threadId,
Mentions = mentions ?? new List<Guid>(),
CreatedAt = DateTime.UtcNow
};
// Step 6: Persist to database
message = await _messageRepo.SaveMessageAsync(message);
_logger.LogInformation(
"Message {MessageId} sent to channel {ChannelId} by user {UserId} (seq: {Seq})",
message.Id, channelId, userId, sequenceNumber);
// Step 7: Handle thread reply
if (threadId.HasValue)
{
var thread = await _threadRepo.GetOrCreateThreadAsync(channelId, threadId.Value);
thread.ReplyCount++;
thread.LastReplyAt = message.CreatedAt;
thread.LastReplyBy = userId;
thread.ParticipantIds.Add(userId);
await _threadRepo.UpdateThreadAsync(thread);
}
// Step 8: Publish to Redis PubSub for real-time delivery
var pubSubChannel = RedisChannel.Literal($"channel:{channelId}");
var messageEvent = new MessageEvent
{
EventType = threadId.HasValue ? "thread.reply" : "message.sent",
MessageId = message.Id,
ChannelId = channelId,
WorkspaceId = channel.WorkspaceId,
UserId = userId,
Content = content,
SequenceNumber = sequenceNumber,
Timestamp = message.CreatedAt,
ThreadId = threadId,
Mentions = message.Mentions
};
await db.PublishAsync(pubSubChannel,
JsonSerializer.Serialize(messageEvent));
// Step 9: Publish to Kafka for async consumers (search, notifications, compliance)
await PublishToKafkaAsync("message-events", messageEvent);
return message;
}
public async Task<Message> EditMessageAsync(
Guid messageId, Guid userId, string newContent)
{
var message = await _messageRepo.GetMessageAsync(messageId)
?? throw new MessageNotFoundException(messageId);
if (message.UserId != userId)
{
throw new UnauthorizedAccessException(
"Only the message author can edit a message");
}
if (message.IsDeleted)
{
throw new InvalidOperationException("Cannot edit a deleted message");
}
message.Content = newContent;
message.IsEdited = true;
message.EditedAt = DateTime.UtcNow;
await _messageRepo.SaveMessageAsync(message);
return message;
}
public async Task DeleteMessageAsync(Guid messageId, Guid userId, bool isAdmin = false)
{
var message = await _messageRepo.GetMessageAsync(messageId)
?? throw new MessageNotFoundException(messageId);
if (message.UserId != userId && !isAdmin)
{
throw new UnauthorizedAccessException(
"Only the message author or admin can delete a message");
}
message.IsDeleted = true;
message.Content = "[message deleted]";
await _messageRepo.SaveMessageAsync(message);
}
public async Task AddReactionAsync(Guid messageId, string emoji, Guid userId)
{
var message = await _messageRepo.GetMessageAsync(messageId)
?? throw new MessageNotFoundException(messageId);
var existing = message.Reactions.FirstOrDefault(r => r.Emoji == emoji);
if (existing != null)
{
if (!existing.UserIds.Contains(userId))
existing.UserIds.Add(userId);
}
else
{
message.Reactions.Add(new Reaction
{
Emoji = emoji,
UserIds = new List<Guid> { userId }
});
}
await _messageRepo.SaveMessageAsync(message);
}
public async Task PinMessageAsync(Guid messageId, Guid userId)
{
var message = await _messageRepo.GetMessageAsync(messageId)
?? throw new MessageNotFoundException(messageId);
message.IsPinned = !message.IsPinned;
await _messageRepo.SaveMessageAsync(message);
}
private async Task PublishToKafkaAsync(string topic, MessageEvent messageEvent)
{
// In production, this uses Confluent.Kafka Producer
_logger.LogDebug(
"Publishing event {EventType} for message {MessageId} to Kafka topic {Topic}",
messageEvent.EventType, messageEvent.MessageId, topic);
await Task.CompletedTask;
}
}
// ============================================================
// WebSocket Connection Manager
// ============================================================
public class WebSocketConnectionManager
{
private readonly ConcurrentDictionary<Guid, ClientConnection> _connections = new();
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<WebSocketConnectionManager> _logger;
public WebSocketConnectionManager(
IConnectionMultiplexer redis,
ILogger<WebSocketConnectionManager> logger)
{
_redis = redis;
_logger = logger;
}
public int OnlineCount => _connections.Count;
public async Task RegisterConnectionAsync(
Guid userId, Guid workspaceId, string[] subscribedChannels)
{
var connection = new ClientConnection
{
UserId = userId,
WorkspaceId = workspaceId,
SubscribedChannels = new HashSet<string>(subscribedChannels),
ConnectedAt = DateTime.UtcNow,
LastHeartbeat = DateTime.UtcNow
};
_connections[userId] = connection;
// Store connection info in Redis for cross-server visibility
var db = _redis.GetDatabase();
var presenceKey = $"presence:{userId}";
await db.StringSetAsync(presenceKey, "online", TimeSpan.FromSeconds(60));
// Register in channel subscriber sets
foreach (var channel in subscribedChannels)
{
await db.SetAddAsync($"channel:subscribers:{channel}", userId.ToString());
}
_logger.LogInformation(
"User {UserId} connected, subscribed to {Count} channels",
userId, subscribedChannels.Length);
}
public async Task UnregisterConnectionAsync(Guid userId)
{
if (_connections.TryRemove(userId, out var connection))
{
var db = _redis.GetDatabase();
await db.StringSetAsync($"presence:{userId}", "offline", TimeSpan.FromMinutes(5));
foreach (var channel in connection.SubscribedChannels)
{
await db.SetRemoveAsync(
$"channel:subscribers:{channel}", userId.ToString());
}
_logger.LogInformation(
"User {UserId} disconnected after {Duration}",
userId, DateTime.UtcNow - connection.ConnectedAt);
}
}
public async Task RefreshHeartbeatAsync(Guid userId)
{
if (_connections.TryGetValue(userId, out var conn))
{
conn.LastHeartbeat = DateTime.UtcNow;
var db = _redis.GetDatabase();
await db.KeyExpireAsync($"presence:{userId}", TimeSpan.FromSeconds(60));
}
}
public bool IsUserOnline(Guid userId)
{
return _connections.ContainsKey(userId);
}
public async Task<int> GetOnlineCountInChannelAsync(Guid channelId)
{
var db = _redis.GetDatabase();
var members = await db.SetMembersAsync(
$"channel:subscribers:{channelId.ToString()}");
return members.Count(m =>
_connections.ContainsKey(Guid.Parse(m.ToString())));
}
public async Task BroadcastToChannelAsync(
string channelId, string message)
{
var db = _redis.GetDatabase();
var subscribers = await db.SetMembersAsync(
$"channel:subscribers:{channelId}");
var buffer = Encoding.UTF8.GetBytes(message);
var tasks = new List<Task>();
foreach (var subscriber in subscribers)
{
var userId = Guid.Parse(subscriber.ToString());
if (_connections.TryGetValue(userId, out var conn) &&
conn.WebSocket != null &&
conn.WebSocket.State == System.Net.WebSockets.WebSocketState.Open)
{
tasks.Add(conn.WebSocket.SendAsync(
new ArraySegment<byte>(buffer),
System.Net.WebSockets.WebSocketMessageType.Text,
true,
CancellationToken.None));
}
}
await Task.WhenAll(tasks);
}
public List<Guid> GetStaleConnections(TimeSpan threshold)
{
var stale = new List<Guid>();
var cutoff = DateTime.UtcNow - threshold;
foreach (var kvp in _connections)
{
if (kvp.Value.LastHeartbeat < cutoff)
stale.Add(kvp.Key);
}
return stale;
}
}
public class ClientConnection
{
public Guid UserId { get; set; }
public Guid WorkspaceId { get; set; }
public HashSet<string> SubscribedChannels { get; set; } = new();
public DateTime ConnectedAt { get; set; }
public DateTime LastHeartbeat { get; set; }
public System.Net.WebSockets.WebSocket? WebSocket { get; set; }
}
// ============================================================
// Presence Service
// ============================================================
public class PresenceService
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<PresenceService> _logger;
public PresenceService(
IConnectionMultiplexer redis,
ILogger<PresenceService> logger)
{
_redis = redis;
_logger = logger;
}
public async Task UpdatePresenceAsync(Guid userId, PresenceState state)
{
var db = _redis.GetDatabase();
var key = $"presence:{userId}";
var ttl = state == PresenceState.Offline
? TimeSpan.FromMinutes(5)
: TimeSpan.FromSeconds(60);
await db.StringSetAsync(key, state.ToString(), ttl);
var update = new PresenceUpdate
{
UserId = userId,
State = state,
Timestamp = DateTime.UtcNow
};
await db.PublishAsync("presence-updates",
JsonSerializer.Serialize(update));
_logger.LogDebug(
"Presence updated: User {UserId} is now {State}",
userId, state);
}
public async Task<PresenceState> GetPresenceAsync(Guid userId)
{
var db = _redis.GetDatabase();
var value = await db.StringGetAsync($"presence:{userId}");
if (!value.HasValue)
return PresenceState.Offline;
return Enum.TryParse<PresenceState>(value.ToString(), out var state)
? state
: PresenceState.Offline;
}
public async Task<Dictionary<Guid, PresenceState>> GetBulkPresenceAsync(
List<Guid> userIds)
{
var db = _redis.GetDatabase();
var tasks = userIds.Select(async userId =>
{
var state = await GetPresenceAsync(userId);
return (userId, state);
});
var results = await Task.WhenAll(tasks);
return results.ToDictionary(r => r.userId, r => r.state);
}
}
// ============================================================
// Channel Service
// ============================================================
public class ChannelService
{
private readonly IChannelRepository _channelRepo;
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<ChannelService> _logger;
public ChannelService(
IChannelRepository channelRepo,
IConnectionMultiplexer redis,
ILogger<ChannelService> logger)
{
_channelRepo = channelRepo;
_redis = redis;
_logger = logger;
}
public async Task<Channel> CreateChannelAsync(
Guid workspaceId, Guid creatorUserId,
string name, ChannelType type, string purpose = "")
{
var channel = new Channel
{
WorkspaceId = workspaceId,
Name = name,
Type = type,
CreatedBy = creatorUserId,
Purpose = purpose,
MemberCount = 1
};
channel = await _channelRepo.CreateChannelAsync(channel);
// Add creator as owner
await _channelRepo.AddMemberAsync(new ChannelMember
{
ChannelId = channel.Id,
UserId = creatorUserId,
Role = UserRole.Owner
});
// Cache channel info
var db = _redis.GetDatabase();
await db.HashSetAsync($"channel:{channel.Id}",
new HashEntry[]
{
new("name", name),
new("type", type.ToString()),
new("workspace_id", workspaceId.ToString()),
new("member_count", 1)
});
_logger.LogInformation(
"Channel {ChannelId} ({Name}) created in workspace {WorkspaceId} by {UserId}",
channel.Id, name, workspaceId, creatorUserId);
return channel;
}
public async Task JoinChannelAsync(Guid channelId, Guid userId)
{
var channel = await _channelRepo.GetChannelAsync(channelId)
?? throw new ChannelNotFoundException(channelId);
if (channel.Type == ChannelType.Private)
{
throw new UnauthorizedAccessException(
"Cannot join a private channel without an invitation");
}
var existing = await _channelRepo.IsMemberAsync(channelId, userId);
if (existing)
{
throw new InvalidOperationException("User is already a member");
}
await _channelRepo.AddMemberAsync(new ChannelMember
{
ChannelId = channelId,
UserId = userId,
Role = UserRole.Member
});
var db = _redis.GetDatabase();
await db.SetAddAsync($"channel:subscribers:{channelId}",
userId.ToString());
}
public async Task<List<Channel>> GetWorkspaceChannelsAsync(
Guid userId, ChannelType? filterType = null)
{
var channels = await _channelRepo.GetUserChannelsAsync(userId);
if (filterType.HasValue)
channels = channels.Where(c => c.Type == filterType.Value).ToList();
return channels;
}
public async Task UpdateTopicAsync(
Guid channelId, Guid userId, string topic)
{
var channel = await _channelRepo.GetChannelAsync(channelId)
?? throw new ChannelNotFoundException(channelId);
channel.Topic = topic;
await _channelRepo.CreateChannelAsync(channel); // upsert
}
}
// ============================================================
// Notification Service
// ============================================================
public class NotificationService : INotificationService
{
private readonly IConnectionMultiplexer _redis;
private readonly WebSocketConnectionManager _connectionManager;
private readonly ILogger<NotificationService> _logger;
public NotificationService(
IConnectionMultiplexer redis,
WebSocketConnectionManager connectionManager,
ILogger<NotificationService> logger)
{
_redis = redis;
_connectionManager = connectionManager;
_logger = logger;
}
public async Task DispatchNotificationAsync(
MessageEvent messageEvent, List<Guid> recipientIds)
{
foreach (var recipientId in recipientIds)
{
// Skip the sender
if (recipientId == messageEvent.UserId)
continue;
// Check if user is online (push via WebSocket)
if (_connectionManager.IsUserOnline(recipientId))
{
_logger.LogDebug(
"User {UserId} is online, will receive via WebSocket",
recipientId);
continue; // WebSocket handles it
}
// Check DND status
var db = _redis.GetDatabase();
var dndKey = $"dnd:{recipientId}";
var isDnd = await db.KeyExistsAsync(dndKey);
if (isDnd)
{
// Buffer notification for later delivery
await db.ListRightPushAsync(
$"notification-buffer:{recipientId}",
JsonSerializer.Serialize(messageEvent));
_logger.LogDebug(
"Notification buffered for DND user {UserId}", recipientId);
continue;
}
// Queue push notification (APNs / FCM)
_logger.LogInformation(
"Push notification queued for user {UserId} from message {MessageId}",
recipientId, messageEvent.MessageId);
}
await Task.CompletedTask;
}
}
// ============================================================
// Search Service Implementation
// ============================================================
public class ElasticsearchSearchService : ISearchService
{
private readonly ILogger<ElasticsearchSearchService> _logger;
public ElasticsearchSearchService(
ILogger<ElasticsearchSearchService> logger)
{
_logger = logger;
}
public async Task IndexMessageAsync(
Message message, Channel channel, User user)
{
_logger.LogDebug(
"Indexing message {MessageId} in channel {ChannelId}",
message.Id, channel.Id);
// In production: POST to Elasticsearch _bulk API
var document = new
{
message_id = message.Id,
channel_id = message.ChannelId,
workspace_id = channel.WorkspaceId,
user_id = message.UserId,
user_name = user.DisplayName,
channel_name = channel.Name,
content = message.Content,
content_type = message.ContentType.ToString(),
thread_id = message.ThreadId,
mentions = message.Mentions,
reaction_count = message.Reactions.Count,
created_at = message.CreatedAt,
is_deleted = message.IsDeleted
};
await Task.CompletedTask;
}
public async Task<List<SearchResult>> SearchAsync(
string query, Guid workspaceId, SearchFilters? filters = null)
{
_logger.LogInformation(
"Searching for '{Query}' in workspace {WorkspaceId}", query, workspaceId);
// In production: Build Elasticsearch query with bool must/filter clauses
var results = new List<SearchResult>();
await Task.CompletedTask;
return results;
}
}
// ============================================================
// Custom Exceptions
// ============================================================
public class DuplicateMessageException : Exception
{
public string IdempotencyKey { get; }
public DuplicateMessageException(string key)
: base($"Duplicate message with key {key}") =>
IdempotencyKey = key;
}
public class ChannelNotFoundException : Exception
{
public Guid ChannelId { get; }
public ChannelNotFoundException(Guid id)
: base($"Channel {id} not found") => ChannelId = id;
}
public class MessageNotFoundException : Exception
{
public Guid MessageId { get; }
public MessageNotFoundException(Guid id)
: base($"Message {id} not found") => MessageId = id;
}
}
26. Slack Connect & Cross-Workspace Collaboration
Slack Connect is one of the platform's most transformative enterprise features, enabling organizations to communicate with external partners, vendors, and clients through shared channels that bridge separate workspaces. Rather than relying on email threads or ad-hoc file sharing, teams collaborate in a single shared environment where messages, files, and integrations flow seamlessly across organizational boundaries. This capability fundamentally changes how enterprises think about inter-company communication, replacing fragmented multi-tool workflows with a unified collaboration surface.
26.1 Shared Channel Architecture
When a channel is shared between two or more workspaces, the system must solve several complex distributed systems challenges: message replication across workspace data stores, consistent message ordering despite clock skew between organizations, permission model reconciliation, and compliance boundary enforcement. Each shared channel maintains a unique global identifier that is distinct from its workspace-local channel ID. Messages posted to the shared channel are written to both workspaces' databases, ensuring each workspace retains its own authoritative copy. A shared channel metadata record tracks all participating workspaces, their access levels, and the origin workspace for each message.
Workspace A)] SA_WS[WebSocket Gateway] SA_MSG[Message Service A] end subgraph "Shared Channel Hub" SCH[Shared Channel
Replication Service] SCH_DB[(Shared Channel
Metadata Store)] end subgraph "Workspace B — Globex Inc" SB_DB[(PostgreSQL
Workspace B)] SB_WS[WebSocket Gateway] SB_MSG[Message Service B] end SA_MSG -->|Write + Replicate| SCH SCH --> SCH_DB SCH -->|Replicate| SB_MSG SB_MSG -->|Write + Replicate| SCH SCH -->|Replicate| SA_MSG SA_WS -->|Push via WS| SA_DB SB_WS -->|Push via WS| SB_DB
The replication service sits between workspaces and handles several critical responsibilities. It normalizes message formats since different workspaces may have different emoji sets or custom reactions. It enforces per-workspace policies — for example, Workspace A may have a DLP rule that blocks credit card numbers while Workspace B does not. The replication service applies the originating workspace's DLP rules to outgoing messages and the receiving workspace's rules to incoming messages, providing a bidirectional compliance layer. It also manages channel membership synchronization, ensuring that when a user is removed from the shared channel in Workspace A, the change is reflected across all connected workspaces.
26.2 Permission Model Across Organizations
Each workspace in a shared channel retains independent permission control. Workspace A may designate certain members as channel admins within the shared channel, but those admin rights only apply to Workspace A's members. A Workspace A admin cannot remove Workspace B members from the channel. The system tracks per-workspace roles in a shared_channel_members table that includes a workspace_id discriminator. This design ensures that organizational sovereignty is preserved — each company maintains full control over its own members while sharing a common communication space.
| Permission | Same Workspace | Cross-Workspace | Description |
|---|---|---|---|
| Send messages | Yes (if member) | Yes (if member) | All channel members can post regardless of workspace |
| Add workspace members | Yes (admin+) | No | Only the originating workspace can add its own members |
| Remove own members | Yes (admin+) | Yes (own workspace admin) | Each workspace manages its own membership independently |
| Edit channel topic | Yes (owner+) | Yes (owner+) | Topic is shared — edits by either workspace owner apply globally |
| Archive channel | Yes (owner+) | Origin workspace only | Only the workspace that created the channel can archive it |
| View file history | Yes | Workspace-scoped | Files uploaded by Workspace A members are visible to Workspace B, but metadata access is scoped |
| Install integrations | Yes (admin+) | Workspace-scoped | Each workspace installs bots independently; bots only see events from their own workspace's users |
| Apply DLP policies | Workspace-wide | Bidirectional | Both workspaces' DLP policies are applied to messages flowing through the shared channel |
26.3 Slack Connect at Scale — Engineering Considerations
At the infrastructure level, shared channels introduce cross-shard communication. Since our sharding strategy partitions data by workspace_id, messages for a shared channel must be written to two different shards (one per workspace). The Shared Channel Replication Service handles this by writing to the originating workspace's shard first (synchronous, for low latency) and then replicating to the destination workspace's shard asynchronously. The replication lag is typically under 200 milliseconds. If the destination workspace's shard is temporarily unavailable, the message is queued in a dedicated Kafka topic (shared-channel-replication) with at-least-once delivery semantics and a dead-letter queue for repeated failures.
Clock synchronization between workspaces is handled using Hybrid Logical Timestamps (HLC) rather than wall-clock time. An HLC combines a physical timestamp component with a logical counter, ensuring that messages from two different workspaces always have a total order even if their clocks differ by up to several seconds. The replication service stamps each replicated message with the HLC from the originating workspace, and the receiving workspace uses this timestamp for ordering rather than its local clock. This eliminates the need for cross-workspace clock synchronization while maintaining consistent message ordering.
Security for shared channels extends beyond DLP. All cross-workspace communication is encrypted in transit using TLS 1.3. At rest, each workspace's data is encrypted with its own KMS key, meaning that Workspace B cannot decrypt Workspace A's data at rest even if it has access to the shared channel messages in transit. Session tokens and authentication credentials are never shared between workspaces — each workspace maintains its own OAuth flow and token lifecycle. Audit logs record all cross-workspace message events, and enterprise administrators can review shared channel activity through a consolidated compliance dashboard.
// Shared Channel Replication Model
public class SharedChannelReplication
{
private readonly IMessageRepository _messageRepo;
private readonly IWorkspaceRepository _workspaceRepo;
private readonly IKafkaProducer _kafkaProducer;
private readonly ILogger<SharedChannelReplication> _logger;
public async Task ReplicateMessageAsync(
SharedChannelMessage message, Guid originWorkspaceId)
{
// Step 1: Write to originating workspace shard (synchronous)
message.OriginWorkspaceId = originWorkspaceId;
message.HybridLogicalClock = await _hlcService.GetNextTimestampAsync();
await _messageRepo.SaveMessageAsync(message);
// Step 2: Identify all connected workspaces
var connectedWorkspaces = await _workspaceRepo
.GetConnectedWorkspacesAsync(message.SharedChannelId);
foreach (var targetWorkspace in connectedWorkspaces
.Where(w => w.Id != originWorkspaceId))
{
// Step 3: Apply target workspace DLP rules
var dlpResult = await _dlpService
.EvaluateAsync(message.Content, targetWorkspace.Id);
if (dlpResult.IsBlocked)
{
_logger.LogWarning(
"Message {MessageId} blocked by DLP for workspace {WorkspaceId}: {Reason}",
message.Id, targetWorkspace.Id, dlpResult.Reason);
continue;
}
// Step 4: Replicate asynchronously to target shard
var replicateEvent = new ReplicationEvent
{
MessageId = message.Id,
SharedChannelId = message.SharedChannelId,
TargetWorkspaceId = targetWorkspace.Id,
Content = message.Content,
HLC = message.HybridLogicalClock,
CreatedAt = message.CreatedAt
};
await _kafkaProducer.ProduceAsync(
"shared-channel-replication", replicateEvent);
}
}
}
Shared channels also interact with the search system. When a user in Workspace A searches their messages, the search index includes messages from Workspace B users that were posted to shared channels the user is a member of. The Elasticsearch index stores a workspace_access array for each message, listing the workspace IDs that have visibility. Search queries filter on this array to ensure results respect cross-workspace access boundaries. This means that a user cannot search for messages in a shared channel they are not a member of, even though the message physically exists in both workspaces' data stores.
27. Slack AI & Intelligence Features
Slack AI represents the integration of large language models and machine learning capabilities directly into the messaging platform, transforming Slack from a communication tool into an intelligent collaboration assistant. These features process the vast corpus of channel messages, threads, and files to surface relevant information, summarize lengthy discussions, and answer natural language questions grounded in organizational knowledge. The AI layer operates as a separate service cluster that reads from the same Kafka event bus used by search and notifications, maintaining its own inference infrastructure with GPU-backed model serving endpoints.
27.1 Channel & Thread Summaries
Channel summaries use a retrieval-augmented generation (RAG) pipeline to produce concise overviews of channel activity. When a user opens a channel they have not visited in a while, or clicks the "Summarize" button, the system fetches the unread messages since their last read timestamp, groups them by topic and thread, and passes the grouped content to a fine-tuned language model. The model produces a structured summary with key discussion points, decisions made, and action items. Thread summaries work similarly but are scoped to a single thread's reply chain, producing a compressed overview of a long discussion that may contain dozens or hundreds of replies.
The retrieval step is critical for accuracy and relevance. Rather than passing hundreds of raw messages to the LLM, the retrieval service first clusters messages using sentence embeddings, identifies the most significant threads based on engagement metrics (reply count, reaction count, participant diversity), and ranks message clusters by information density. Only the top clusters are passed to the LLM, keeping the input token count manageable while ensuring the summary captures the most important discussions. The system also respects file-sharing events, including references to shared documents in the summary ("@alice shared a design doc for the API redesign" rather than omitting file references).
27.2 Search Answers
Search Answers augment the traditional keyword-based search with natural language question answering. When a user types a question like "What was the decision about the new authentication provider?" instead of requiring them to scan through search results, the AI system first identifies relevant messages using semantic search (embedding similarity), then passes those messages to the LLM with a prompt asking it to answer the question based on the provided context. The response includes the answer along with source citations — clickable links to the specific messages that informed the answer. This allows users to verify the answer by reading the original discussion.
The semantic search component uses a dual-encoder model for query-document similarity. The user's query is encoded into a vector embedding, and this embedding is compared against pre-computed embeddings of messages stored in a vector database (Pinecone or pgvector). The top 20 most similar messages are retrieved and passed to the LLM as context. The LLM is prompted with a "answer only from the provided context" constraint to prevent hallucination — if the context does not contain enough information to answer the question, the system returns "I couldn't find enough information to answer this question" rather than fabricating an answer.
| AI Feature | Input | Processing | Output | Latency Target |
|---|---|---|---|---|
| Channel Summary | Unread messages (up to 500) | Topic clustering → RAG summarization | Bullet-point summary with decisions and action items | < 3 seconds |
| Thread Recap | Thread replies (up to 200) | Sequential summarization with context windowing | Concise recap with key viewpoints | < 2 seconds |
| Search Answer | Natural language question | Semantic search → RAG answer generation | Answer with source message citations | < 4 seconds |
| Message Translation | Message content + target language | Context-aware translation | Translated message with "Translated from English" label | < 1 second |
| Action Item Extraction | Channel or thread messages | NER + relation extraction | List of action items with owners and deadlines | < 3 seconds |
| Meeting Notes Generation | Huddle transcript + thread context | Transcript processing → structured notes | Formatted meeting notes with attendees, topics, decisions | < 5 seconds |
| Smart Notifications | Message content + user context | Urgency classification + relevance scoring | Priority-ranked notifications with relevance explanations | < 500ms |
| Emoji Suggestion | Message content | Sentiment analysis + emoji mapping | Top 3 suggested emoji reactions | < 200ms |
27.3 Conversation Summaries and AI-Powered Thread Recaps
AI-powered thread recaps are one of the most valued features for enterprise users dealing with long-running threads. A thread with 50+ replies is difficult to follow, especially for someone joining mid-conversation. The recap system processes the entire thread, identifies key decision points and consensus shifts, and produces a narrative summary that captures the evolution of the discussion. For example, a thread recap might read: "The team initially proposed using Redis for caching (3 votes), then discussed DynamoDB as an alternative (2 votes pointing out better scalability), and ultimately decided on Redis with a fallback migration plan to DynamoDB in Q4. The primary concern was operational complexity."
The thread recap model uses a hierarchical summarization approach. First, messages are grouped into logical segments based on topic shifts (detected via embedding similarity between consecutive messages). Each segment is summarized independently. Then, a second pass summarizes across segment summaries, producing the final thread recap. This two-level approach handles threads with tens of thousands of tokens that would otherwise exceed the LLM's context window. The segment boundaries are stored alongside the summary, enabling users to click on a summary point and jump to the corresponding section of the thread.
The infrastructure powering Slack AI runs on a dedicated GPU cluster separate from the main messaging infrastructure. This isolation ensures that AI workloads — which are bursty and compute-intensive — do not impact the latency-sensitive message delivery pipeline. The AI service cluster includes model serving endpoints (using NVIDIA Triton Inference Server or similar), a vector database for semantic search embeddings, a summarization cache backed by Redis (TTL 15 minutes for channel summaries, 1 hour for thread recaps), and a feedback loop that collects user ratings on summary quality to fine-tune the models over time. All AI processing is subject to data residency requirements — a workspace hosted in EU-West has its AI processing routed to EU-based GPU nodes to ensure GDPR compliance.
// AI Summary Service
public class AiSummaryService
{
private readonly IMessageRepository _messageRepo;
private readonly IVectorDatabase _vectorDb;
private readonly ILlmInferenceClient _llmClient;
private readonly IDatabase _cache;
private readonly ILogger<AiSummaryService> _logger;
public async Task<ChannelSummary> GenerateChannelSummaryAsync(
Guid channelId, Guid userId, DateTime since)
{
// Step 1: Check cache
var cacheKey = $"ai:summary:channel:{channelId}:{since:yyyyMMdd}";
var cached = await _cache.StringGetAsync(cacheKey);
if (cached.HasValue)
return JsonSerializer.Deserialize<ChannelSummary>(cached!);
// Step 2: Fetch unread messages
var messages = await _messageRepo
.GetChannelHistoryAsync(channelId, limit: 500, before: null);
var unread = messages
.Where(m => m.CreatedAt >= since && !m.IsDeleted)
.ToList();
if (unread.Count == 0)
return new ChannelSummary { Empty = true, Message = "No new messages" };
// Step 3: Cluster messages by topic using embeddings
var clusters = await _vectorDb
.ClusterMessagesAsync(unread, numClusters: 10);
// Step 4: Rank clusters by engagement and information density
var rankedClusters = clusters
.OrderByDescending(c => c.EngagementScore)
.ThenByDescending(c => c.MessageCount)
.Take(5)
.ToList();
// Step 5: Generate summary via LLM
var prompt = BuildSummaryPrompt(rankedClusters);
var llmResponse = await _llmClient.GenerateAsync(prompt,
maxTokens: 512,
temperature: 0.3f);
var summary = new ChannelSummary
{
ChannelId = channelId,
KeyPoints = llmResponse.KeyPoints,
Decisions = llmResponse.Decisions,
ActionItems = llmResponse.ActionItems,
GeneratedAt = DateTime.UtcNow,
MessageCount = unread.Count,
TimeRange = (unread.Last().CreatedAt - unread.First().CreatedAt)
};
// Step 6: Cache result
await _cache.StringSetAsync(cacheKey,
JsonSerializer.Serialize(summary),
TimeSpan.FromMinutes(15));
return summary;
}
public async Task<ThreadRecap> GenerateThreadRecapAsync(Guid threadId)
{
var messages = await _messageRepo.GetThreadRepliesAsync(threadId, 200);
// Hierarchical summarization: segment → summarize → merge
var segments = SegmentThreadByTopic(messages);
var segmentSummaries = new List<string>();
foreach (var segment in segments)
{
var segmentSummary = await _llmClient.GenerateAsync(
$"Summarize this thread segment concisely:\n{string.Join("\n", segment.Select(m => $"{m.UserId}: {m.Content}"))}",
maxTokens: 128,
temperature: 0.2f);
segmentSummaries.Add(segmentSummary.Text);
}
var finalRecap = await _llmClient.GenerateAsync(
$"Create a narrative recap of this discussion:\n{string.Join("\n---\n", segmentSummaries)}",
maxTokens: 256,
temperature: 0.3f);
return new ThreadRecap
{
ThreadId = threadId,
Recap = finalRecap.Text,
SegmentCount = segments.Count,
TotalMessages = messages.Count
};
}
private List<List<Message>> SegmentThreadByTopic(List<Message> messages)
{
var segments = new List<List<Message>>();
var currentSegment = new List<Message> { messages[0] };
for (int i = 1; i < messages.Count; i++)
{
var similarity = _embeddingService
.CosineSimilarity(messages[i - 1].Content, messages[i].Content);
if (similarity < 0.4f)
{
segments.Add(currentSegment);
currentSegment = new List<Message>();
}
currentSegment.Add(messages[i]);
}
segments.Add(currentSegment);
return segments;
}
}
The user experience for AI features is designed to be optional and transparent. Summaries are never shown automatically — the user must explicitly request them by clicking a "Summarize" button or using the /ai summarize slash command. Each summary includes a disclosure that it was AI-generated, along with a feedback widget (thumbs up/down) that feeds into the model improvement pipeline. Enterprise administrators can disable AI features entirely for their workspace if required by policy, and individual users can opt out of having their messages included in summary generation for other users. This opt-out is enforced at the retrieval layer — when building the context for a summary, messages from opted-out users are filtered before being passed to the LLM.
28. Conclusion
Designing an enterprise messaging system like Slack requires careful consideration of numerous interconnected components. From the message send pipeline that guarantees per-channel ordering, to the real-time WebSocket infrastructure that delivers messages in under 200 milliseconds, to the search system that indexes billions of messages, every component must be engineered for reliability, performance, and scale.
The key architectural decisions we covered include:
- Event-Driven Architecture: Kafka as the backbone for decoupling write-path from search indexing, notifications, and compliance processing
- Per-Channel Sharding: Using workspace_id as the shard key and monthly partitioning for messages, enabling efficient workspace-local queries
- Redis-Powered Real-Time: Redis PubSub for WebSocket fan-out, Redis for presence state, and Redis for caching hot data
- Hybrid Fan-Out: WebSocket push for online users, push notifications for offline users, batch digests for muted channels
- Idempotent Operations: Client-generated idempotency keys prevent duplicate messages across retries
- Multi-Region with Strong Consistency: Synchronous replication within region, async across regions, with conflict resolution for shared channels
Building a Slack-scale messaging system is a massive engineering undertaking, but the principles and patterns discussed in this article provide a solid foundation. The C# implementation demonstrates how the core services interact in practice, covering message sending, WebSocket management, presence tracking, notifications, and search.
For system design interviews, focus on the message delivery pipeline end-to-end, the trade-offs between consistency and availability, and the specific numbers that drive your capacity decisions. Understanding the "why" behind each architectural choice is more important than memorizing the components. Be prepared to dive deep into any subsystem — whether it is the fan-out strategy for large channels, the search indexing pipeline, the WebSocket connection lifecycle, or the multi-region failover mechanism. Interviewers at senior+ levels expect you to reason about trade-offs under real-world constraints, not just recite textbook patterns.
The most common pitfall in designing messaging systems is underestimating the complexity of the "last mile" — getting messages delivered reliably to millions of concurrent clients with sub-second latency while maintaining strict ordering guarantees, handling presence, and processing millions of bot-triggered events simultaneously. The secondary challenge is building a search system that can handle billions of documents with sub-second query latency while supporting complex boolean queries, date range filters, and personalized ranking. These are the areas where interviewers will probe deepest, and where the difference between a senior and staff-level answer lies.
As you build and scale messaging systems in production, remember that the architecture is never truly "done." The requirements evolve — from adding AI-powered message summaries and smart notifications, to supporting ephemeral messaging and disappearing messages, to integrating large language models as conversational agents within channels. Each new feature demands careful consideration of how it fits into the existing architecture, how it impacts performance at scale, and how it maintains the security and compliance guarantees that enterprise customers depend on.
- Slack Engineering Blog: Architecture of a Real-Time Distributed System
- Martin Kleppmann: Designing Data-Intensive Applications (Chapters on Replication, Partitioning, and Transactions)
- Real-World Distributed Systems: Discord, Slack, and Teams Architecture Deep Dives
- AWS Well-Architected Framework: Reliability Pillar
Written by Ayodhyya • Last updated July 14, 2026