Skip to content

System Design Case Studies

The best way to learn system design is by working through real problems. This page presents three classic system design case studies, each following the structured interview framework: requirements gathering, estimation, high-level design, detailed design, and scaling considerations.

URL Shortener

Design a service like bit.ly that converts long URLs into short, unique links and redirects users. Covers hashing, base62 encoding, and read-heavy optimization.

Chat System

Design a real-time messaging system like WhatsApp supporting 1:1 and group chat. Covers WebSockets, message delivery guarantees, and presence detection.

News Feed

Design a social media feed like Twitter’s home timeline. Covers fan-out strategies, ranking algorithms, and feed generation at scale.


Case Study 1: Design a URL Shortener (like bit.ly)

Step 1: Requirements Clarification

Functional Requirements:

  • Given a long URL, generate a short, unique URL
  • When users visit the short URL, redirect them to the original URL
  • Users can optionally create custom short URLs
  • Links expire after a configurable time (default: 5 years)
  • Analytics: track click count per URL

Non-Functional Requirements:

  • Very high availability (reads must always work)
  • Low latency redirects (under 100ms)
  • Short URLs should not be guessable (not sequential)

Out of Scope: User accounts, rate limiting per user, link preview generation.

Step 2: Estimation

Traffic:
- 100M new URLs created per month (writes)
- Read:Write ratio = 100:1
- 10B redirects per month (reads)
- Writes: 100M / (30 * 24 * 3600) β‰ˆ 40 URLs/sec
- Reads: 10B / (30 * 24 * 3600) β‰ˆ 3,800 reads/sec
Storage:
- Each URL record: ~500 bytes (short URL, long URL, timestamps, metadata)
- 100M * 500 bytes = 50 GB/month
- 5 years: 50 * 60 = 3 TB total
Memory (caching top 20%):
- 10B requests/month β†’ 330M requests/day
- Cache top 20% of daily URLs: 66M * 500 bytes β‰ˆ 33 GB
- Fits in a single Redis instance or small cluster

Step 3: High-Level Design

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β”‚ POST β”‚ β”‚ β”‚ β”‚
β”‚ Client │───────►│ API │───────►│ Database β”‚
β”‚ β”‚ β”‚ Server β”‚ β”‚ (URL Store) β”‚
β”‚ β”‚ GET β”‚ β”‚ β”Œβ”€β”€β”€β–Ίβ”‚ β”‚
β”‚ │───────►│ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ │◄───────│ β”‚β”€β”€β”€β”˜
β”‚ β”‚ 302 β”‚ β”‚β—„β”€β”€β”€β”€β”€β”€β–Ίβ”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Cache β”‚
β”‚ (Redis) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

API Design:

POST /api/v1/shorten
Request: { "long_url": "https://...", "custom_alias": "my-link", "expiry": "2029-01-01" }
Response: { "short_url": "https://short.ly/aB3xK9", "expires_at": "2029-01-01T00:00:00Z" }
GET /{short_code}
Response: 302 Redirect to original URL
Headers: Location: https://original-long-url.com/path
GET /api/v1/stats/{short_code}
Response: { "short_url": "...", "long_url": "...", "clicks": 12345, "created_at": "..." }

Step 4: Detailed Design

URL Shortening Algorithm

We need to generate a unique, short string for each URL. A 7-character base62 string provides 62^7 = 3.5 trillion unique combinations β€” more than enough.

Approach 1: Hash + Truncate

long_url = "https://www.example.com/very/long/path?query=value"
hash = MD5(long_url) = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
take first 7 chars β†’ "d41d8cd" (but this is hex, not base62)
convert to base62 β†’ "aB3xK9p"
Problem: Hash collisions are possible with truncation.
Solution: Check database. If collision, append a counter and rehash.

Approach 2: Counter-Based with Base62 Encoding

import string
ALPHABET = string.digits + string.ascii_lowercase + string.ascii_uppercase
BASE = len(ALPHABET) # 62
def encode_base62(num: int) -> str:
"""Convert a number to base62 string."""
if num == 0:
return ALPHABET[0]
result = []
while num > 0:
result.append(ALPHABET[num % BASE])
num //= BASE
return ''.join(reversed(result))
def decode_base62(s: str) -> int:
"""Convert a base62 string back to a number."""
num = 0
for char in s:
num = num * BASE + ALPHABET.index(char)
return num
# Examples:
# encode_base62(1000000) β†’ "4c92"
# encode_base62(56800235584) β†’ "aB3xK9p" (7 chars)

Approach 3: Pre-Generated Key Service (Recommended)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Key Gen │────►│ Key Database β”‚
β”‚ Service β”‚ β”‚ β”‚
β”‚ β”‚ β”‚ Used: [aB3, xK9...]β”‚
β”‚ Pre-generates β”‚ Unused: [pQ7, mN2..]β”‚
β”‚ millions of β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ unique keys β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”‚ Batch of unused keys
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ App Server β”‚ Takes a key from local batch
β”‚ (in-memory β”‚ when creating new short URL.
β”‚ key batch) β”‚ Requests new batch when running low.
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • Pre-generate unique 7-character keys and store them in a database
  • Application servers fetch batches of unused keys
  • No collision checking needed β€” keys are guaranteed unique
  • If a server crashes, its unused keys are simply lost (acceptable given 3.5 trillion possible keys)

Database Schema

CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(7) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
click_count BIGINT DEFAULT 0,
user_id BIGINT -- optional, for registered users
);
CREATE INDEX idx_expires_at ON urls(expires_at) WHERE expires_at IS NOT NULL;

Database choice: Use a NoSQL key-value store (DynamoDB or Cassandra) for the URL mapping since the access pattern is simple key-value lookup. Use a relational database for analytics if complex queries are needed.

Read Path (Redirect)

1. Client requests GET /aB3xK9p
2. Check Redis cache for key "aB3xK9p"
3. Cache HIT β†’ redirect immediately (< 10ms)
4. Cache MISS β†’ query database β†’ store in cache β†’ redirect
5. Increment click counter (async, via message queue)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” GET /aB3xK9p β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Client │──────────────────►│ App Svr β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ β”‚ 1. Check β”‚β”€β”€β–Ίβ”Œβ”€β”€β”€β”€β”€β”€β”€β”
β”‚ β”‚ β”‚ cache β”‚ β”‚ Redis β”‚
β”‚ β”‚ β”‚ │◄──│ β”‚
β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ 302 Redirect β”‚ 2. (miss)β”‚β”€β”€β–Ίβ”Œβ”€β”€β”€β”€β”€β”€β”
β”‚ │◄──────────────────│ query β”‚ β”‚ DB β”‚
β”‚ β”‚ Location: ... β”‚ DB │◄──│ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜

301 vs 302 Redirect

CodeMeaningBrowser BehaviorUse When
301Moved PermanentlyBrowser caches redirectYou want to reduce server load
302Found (Temporary)Browser asks server every timeYou need accurate click analytics

For a URL shortener, use 302 if analytics are important (so every click hits the server), or 301 for better performance.

Step 5: Scaling Considerations

Full Architecture at Scale:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Clients │───────►│ CDN β”‚ (cache 302 redirects)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
β”‚ Load β”‚
β”‚ Balancer β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚App Svr β”‚ β”‚App Svr β”‚ β”‚App Svr β”‚
β”‚ + Key β”‚ β”‚ + Key β”‚ β”‚ + Key β”‚
β”‚ Batch β”‚ β”‚ Batch β”‚ β”‚ Batch β”‚
β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚
β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
β”‚ Redis Cluster β”‚
β”‚ (cache hot short URLs) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ (cache miss)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚DB Shardβ”‚ β”‚DB Shardβ”‚ β”‚DB Shardβ”‚
β”‚ 1 β”‚ β”‚ 2 β”‚ β”‚ 3 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”
β”‚ Kafka │──► Analytics Pipeline
β”‚ (clicks)│──► Click Aggregation
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Scaling strategies:

  • Cache heavily: 80/20 rule β€” 20% of URLs generate 80% of traffic. Cache these in Redis.
  • Shard the database: Shard by hash of short_code for even distribution.
  • Separate analytics: Use a message queue (Kafka) to asynchronously record clicks. Aggregate counts in a separate analytics store.
  • Key generation service: Run multiple instances with non-overlapping key ranges to avoid coordination.

Case Study 2: Design a Chat System (like WhatsApp)

Step 1: Requirements Clarification

Functional Requirements:

  • 1:1 messaging between users
  • Group chat (up to 500 members)
  • Online/offline status (presence indicators)
  • Message delivery receipts (sent, delivered, read)
  • Message history and persistence
  • Support for text messages (media support is out of scope)

Non-Functional Requirements:

  • Real-time delivery (under 500ms for 1:1 messages)
  • High availability β€” messaging should never go down
  • Messages must not be lost (durability)
  • Message ordering must be preserved within a conversation

Step 2: Estimation

Users: 500M monthly active users, 100M daily active
Messages: 50 messages/day per user
- 100M * 50 = 5B messages/day
- 5B / 86400 β‰ˆ 58,000 messages/sec
Storage per message: ~200 bytes (text + metadata)
- 5B * 200 bytes = 1 TB/day
- 365 TB/year
Connections: 100M concurrent WebSocket connections
- Each connection: ~10KB memory
- Total: 100M * 10KB = 1 TB memory for connections
- Need ~1000 servers (each handling 100K connections)

Step 3: High-Level Design

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” WebSocket β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ User A │◄───────────►│ Chat │◄────►│ Message β”‚
β”‚ (sender) β”‚ β”‚ Server β”‚ β”‚ Queue β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Cluster β”‚ β”‚ (Kafka) β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” WebSocket β”‚ Chat β”‚ β”‚ Message β”‚
β”‚ User B │◄───────────►│ Server β”‚ β”‚ Store β”‚
β”‚(receiver)β”‚ β”‚ Cluster β”‚ β”‚ (Cassandra) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 4: Detailed Design

Connection Management

Each user maintains a persistent WebSocket connection to a chat server. A connection registry tracks which user is connected to which server.

β”Œβ”€β”€β”€β”€β”€β”€β” WS β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” WS β”Œβ”€β”€β”€β”€β”€β”€β”
β”‚User A│◄────►│Chat Svr 1β”‚ β”‚Chat Svr 2│◄────►│User Bβ”‚
β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Connection Registry (Redis) β”‚
β”‚ β”‚
β”‚ user_A β†’ chat_server_1 β”‚
β”‚ user_B β†’ chat_server_2 β”‚
β”‚ user_C β†’ chat_server_1 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

1:1 Message Flow

User A sends "Hello" to User B:
1. User A ──(WebSocket)──► Chat Server 1
2. Chat Server 1:
a. Generate message_id (timestamp + sequence)
b. Store message in database
c. Look up User B's connection in registry
d. User B is on Chat Server 2
3. Chat Server 1 ──(message queue)──► Chat Server 2
4. Chat Server 2 ──(WebSocket)──► User B
5. User B sends delivery ACK ──► Chat Server 2
6. Chat Server 2 ──► Update message status to "delivered"
Timeline:
User A Server User B
β”‚ β”‚ β”‚
│── msg "Hello" ─────►│ β”‚
β”‚ │── store in DB β”‚
│◄── ACK (sent) ──────│ β”‚
β”‚ │── forward ─────────►│
β”‚ β”‚ β”‚
β”‚ │◄── ACK (delivered) ──│
│◄── delivered ───────│ β”‚
β”‚ β”‚ β”‚
β”‚ │◄── ACK (read) ──────│
│◄── read ────────────│ β”‚

Group Chat

For group messages, the message is sent once and fanned out to all group members.

User A sends "Hi everyone" to Group (A, B, C, D):
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ User A │──► Chat Svr 1 ──► Kafka ───► β”‚ User B β”‚ (online, on Svr 2)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”œβ”€β”€β–Ί β”‚ User C β”‚ (online, on Svr 3)
└──► β”‚ User D β”‚ (offline β†’ push notification)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Small groups (< 500):
β†’ Fan-out on write: send to each member's queue
Large groups / channels (500+):
β†’ Fan-out on read: members pull when they come online

Message Storage

-- Messages table (in Cassandra for write-heavy workload)
-- Partition key: conversation_id (ensures all messages in a
-- conversation are on the same partition for efficient reads)
-- Clustering key: message_id (sorted by timestamp)
CREATE TABLE messages (
conversation_id UUID,
message_id TIMEUUID, -- timestamp-based UUID
sender_id UUID,
content TEXT,
message_type TEXT, -- 'text', 'image', 'system'
status TEXT, -- 'sent', 'delivered', 'read'
created_at TIMESTAMP,
PRIMARY KEY (conversation_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);
-- Conversation list per user
CREATE TABLE user_conversations (
user_id UUID,
conversation_id UUID,
last_message_at TIMESTAMP,
last_message_text TEXT,
unread_count INT,
PRIMARY KEY (user_id, last_message_at)
) WITH CLUSTERING ORDER BY (last_message_at DESC);

Why Cassandra?

  • Excellent write throughput (append-only, LSM tree)
  • Partition by conversation_id keeps related messages together
  • Time-series ordering within a partition
  • Linear horizontal scalability
  • Tunable consistency (write to quorum for durability)

Presence Detection (Online/Offline Status)

Approach: Heartbeat-based detection
User A connects:
β†’ Set "user_A:status" = "online" in Redis (with 30s TTL)
β†’ Client sends heartbeat every 10 seconds
β†’ Each heartbeat resets the TTL
User A disconnects (graceful):
β†’ Delete "user_A:status" from Redis
β†’ Notify relevant users via pub/sub
User A disconnects (crash/network loss):
β†’ Heartbeat stops
β†’ TTL expires after 30 seconds
β†’ Status automatically becomes "offline"
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” heartbeat β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”
β”‚ User A │──(every 10s)─►│ Chat Svr │──SETEX──►│ Redis β”‚
β”‚ β”‚ β”‚ β”‚ 30s TTL β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”˜

Handling Offline Users

When a user is offline, messages are stored and delivered when they reconnect:

1. User B sends message to User A (offline)
2. Chat Server stores message in database with status = "sent"
3. Send push notification to User A's mobile device
4. When User A comes online:
a. Query all messages with status = "sent" for User A's conversations
b. Deliver all pending messages via WebSocket
c. Update status to "delivered"

Step 5: Scaling Considerations

Full Architecture:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ API Gateway β”‚
β”‚ (REST for auth, β”‚
Mobile ─────────────────►│ profile, etc.) β”‚
Apps β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
WebSocket ────────────►│ WS Load Balancerβ”‚ (sticky sessions by user)
Connections β””β”€β”€β”€β”¬β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ └────────┐
β–Ό β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚Chat Svr 1β”‚ β”‚Chat Svr 2β”‚ β”‚Chat Svr Nβ”‚
β”‚ (100K β”‚ β”‚ (100K β”‚ β”‚ (100K β”‚
β”‚ conns) β”‚ β”‚ conns) β”‚ β”‚ conns) β”‚
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”
β”‚ Redis Cluster β”‚
β”‚ (connection registry + presence) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Kafka Cluster β”‚
β”‚ (message routing between servers) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Cassandra Cluster β”‚
β”‚ (message persistence) β”‚
β”‚ β”‚
β”‚ Shard by conversation_id β”‚
β”‚ RF=3 for durability β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Push Notification Service β”‚
β”‚ (APNs for iOS, FCM for Android) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key scaling decisions:

  • WebSocket servers: Each handles ~100K connections. Scale horizontally. Use consistent hashing to route users to servers.
  • Message ordering: Use Kafka partitions keyed by conversation_id to ensure messages within a conversation are processed in order.
  • Group chat fan-out: For small groups (< 500), fan-out on write. For large channels, fan-out on read.
  • End-to-end encryption: Messages are encrypted on the sender’s device and decrypted on the receiver’s device. The server never sees plaintext content.

Case Study 3: Design a News Feed (like Twitter)

Step 1: Requirements Clarification

Functional Requirements:

  • Users can publish posts (tweets)
  • Users follow other users
  • Users see a personalized feed of posts from people they follow
  • Feed is sorted by relevance (not purely chronological)
  • Support for likes, retweets, and replies

Non-Functional Requirements:

  • Feed generation must be fast (under 500ms p99)
  • High availability β€” the feed should always load
  • Eventual consistency is acceptable (a new post can take a few seconds to appear in followers’ feeds)
  • Support for users with millions of followers (celebrities)

Step 2: Estimation

Users: 500M monthly active, 200M daily active
Posts: 10M new posts per day
Follows: Average user follows 200 people
Feed: Each user views feed ~10 times/day
Feed generation:
- 200M users * 10 views = 2B feed requests/day
- 2B / 86400 β‰ˆ 23,000 feed requests/sec
Post writes:
- 10M posts/day = ~115 posts/sec
Storage per post: ~1KB (text, metadata, references)
- 10M * 1KB = 10 GB/day
- 3.65 TB/year

Step 3: High-Level Design

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Client │───────►│ Post Service │─────►│ Post Store β”‚
β”‚ β”‚ POST β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚
β”‚ β”‚ β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
β”‚ β”‚ β”‚ Fan-out β”‚
β”‚ β”‚ β”‚ Service β”‚
β”‚ β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚
β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ │───────►│ Feed Service │─────►│ Feed Cache β”‚
β”‚ β”‚ GET β”‚ β”‚ β”‚ (Redis) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 4: Detailed Design

The Fan-Out Problem

When a user publishes a post, how do we get it into all their followers’ feeds?

Approach 1: Fan-Out on Write (Push Model)

When a post is created, immediately push it to every follower’s feed cache.

Alice posts "Hello world!" (Alice has 1000 followers)
1. Store post in posts table
2. Fetch Alice's follower list: [Bob, Carol, Dave, ... 997 more]
3. For each follower, prepend post to their feed cache:
- LPUSH feed:Bob post_id_123
- LPUSH feed:Carol post_id_123
- LPUSH feed:Dave post_id_123
- ... (1000 operations)
β”Œβ”€β”€β”€β”€β”€β”€β”€β” Post β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Fan-out β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Alice │──────────►│ Post Svc │────────────►│ Feed Cache β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚
β”‚ feed:Bob [123, 120, 118...]β”‚
β”‚ feed:Carol [123, 122, 115...]β”‚
β”‚ feed:Dave [123, 119, 117...]β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Pros: Feed reads are instant (pre-computed), simple read path Cons: Write amplification for users with millions of followers, wasted work for inactive users

Approach 2: Fan-Out on Read (Pull Model)

When a user opens their feed, fetch posts from all the people they follow and merge them.

Bob opens his feed (Bob follows 200 people):
1. Fetch Bob's following list: [Alice, Charlie, Eve, ... 197 more]
2. For each followed user, fetch their recent posts:
- SELECT * FROM posts WHERE user_id = Alice ORDER BY created_at DESC LIMIT 20
- SELECT * FROM posts WHERE user_id = Charlie ORDER BY created_at DESC LIMIT 20
- ... (200 queries)
3. Merge all posts and sort by relevance/time
4. Return top 20
β”Œβ”€β”€β”€β”€β”€β” GET /feed β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” 200 queries β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Bob │────────────►│ Feed Svc │──────────────►│ Post DB β”‚
β”‚ │◄────────────│ (merge) │◄──────────────│ β”‚
β””β”€β”€β”€β”€β”€β”˜ sorted feedβ””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Pros: No write amplification, no wasted work for inactive users Cons: Slow feed reads (many queries to merge), high read latency

Approach 3: Hybrid (Recommended)

Use fan-out on write for most users, fan-out on read for celebrities (users with millions of followers).

Hybrid Strategy:
Post by regular user (< 10K followers):
β†’ Fan-out on write (push to all follower feeds)
Post by celebrity (> 10K followers):
β†’ Do NOT fan-out on write
β†’ When a user fetches their feed:
1. Read pre-computed feed from cache (regular users' posts)
2. Fetch recent posts from followed celebrities
3. Merge and rank
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Feed Generation β”‚
β”‚ β”‚
β”‚ User opens feed: β”‚
β”‚ 1. Get pre-built feed from cache ──► [P1, P3, P5]β”‚
β”‚ 2. Get celebrity posts (fan-out read) ──► [P2, P4] β”‚
β”‚ 3. Merge + Rank ──► [P2, P1, P4, P3, P5]β”‚
β”‚ 4. Return top 20 posts β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Data Model

-- Users table
CREATE TABLE users (
user_id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
display_name VARCHAR(100),
follower_count BIGINT DEFAULT 0,
is_celebrity BOOLEAN DEFAULT FALSE -- true if follower_count > 10K
);
-- Posts table
CREATE TABLE posts (
post_id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(user_id),
content TEXT NOT NULL,
media_urls TEXT[],
like_count BIGINT DEFAULT 0,
retweet_count BIGINT DEFAULT 0,
reply_count BIGINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_posts_user_time ON posts(user_id, created_at DESC);
-- Follows table (who follows whom)
CREATE TABLE follows (
follower_id BIGINT,
followee_id BIGINT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX idx_followee ON follows(followee_id);

Feed Cache Structure (Redis)

Key: "feed:{user_id}"
Value: Sorted Set of post_ids scored by timestamp (or relevance score)
ZADD feed:bob 1700000001 "post_123"
ZADD feed:bob 1700000050 "post_456"
ZADD feed:bob 1700000100 "post_789"
# Fetch feed (latest 20 posts)
ZREVRANGE feed:bob 0 19
# Trim old entries (keep last 1000)
ZREMRANGEBYRANK feed:bob 0 -1001

Feed Ranking

A simple chronological feed is straightforward, but a ranked feed improves engagement. A basic ranking formula:

def calculate_relevance_score(post, user):
"""Score a post for a specific user's feed."""
score = 0.0
# Recency (decays over time)
age_hours = (now() - post.created_at).total_seconds() / 3600
recency_score = 1.0 / (1.0 + age_hours)
score += recency_score * 40 # weight: 40%
# Engagement signals
engagement = (
post.like_count * 1.0 +
post.retweet_count * 2.0 +
post.reply_count * 3.0
)
engagement_score = math.log(1 + engagement)
score += engagement_score * 30 # weight: 30%
# Relationship strength (how often user interacts with author)
interaction_count = get_interaction_count(user.id, post.author_id)
relationship_score = math.log(1 + interaction_count)
score += relationship_score * 20 # weight: 20%
# Content type boost
if post.has_media:
score += 5 # weight: 10%
if post.is_thread:
score += 3
return score

Post Publishing Flow

1. User creates post via API
2. Post Service:
a. Validate content (length, prohibited content)
b. Store in posts table
c. Upload media to object storage (S3)
d. Publish event to Kafka topic "new_posts"
3. Fan-out Service (consumes from Kafka):
a. Check if author is celebrity (follower_count > 10K)
b. If regular user: fetch follower list, push to each feed cache
c. If celebrity: skip fan-out (handled at read time)
4. Notification Service (consumes from Kafka):
a. Send push notifications to users with notifications enabled
5. Search Indexing Service (consumes from Kafka):
a. Index post in Elasticsearch for search
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” POST β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Event β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ User │─────────►│ Post Svc │────────►│ Kafka β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚Fan-out β”‚β”‚Notif. β”‚β”‚Search β”‚
β”‚Service β”‚β”‚Service β”‚β”‚Indexer β”‚
β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
Push to follower
feed caches
β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Redis Cluster β”‚
β”‚ (feed caches) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 5: Scaling Considerations

Full Architecture:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Clients β”‚
β”‚ (Mobile Apps, Web Browsers, Third-Party via API) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
β”‚ CDN β”‚ (images, videos, static assets)
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
β”‚ API GW / β”‚ (auth, rate limiting, routing)
β”‚ Load Bal β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Post Svc β”‚ β”‚ Feed Svc β”‚ β”‚ User Svc β”‚
β”‚ β”‚ β”‚ β”‚ β”‚ β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
β”‚Post DB β”‚ β”‚Redis Feed β”‚ β”‚ User DB β”‚
β”‚(sharded)β”‚ β”‚Cache Clusterβ”‚ β”‚(sharded) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
β”‚ Kafka │──► Fan-out Workers (auto-scaled)
β”‚ Cluster │──► Notification Workers
β”‚ │──► Search Index Workers
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”€β”€β–Ί Analytics Pipeline
β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Elasticsearch β”‚ (post search)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key scaling decisions:

ComponentStrategyRationale
Post DBShard by user_idCo-locate a user’s posts for efficient retrieval
Feed cacheRedis Cluster, shard by user_idEven distribution of feed data
Fan-out workersAuto-scale based on Kafka lagHandle spikes when celebrities post
Celebrity detectionThreshold-based (> 10K followers)Avoid massive fan-out for celebrities
Feed TTLKeep last 1000 posts per user, expire after 7 daysBound memory usage
Media storageS3 + CDNScalable blob storage with global delivery
SearchElasticsearch clusterFull-text search across all posts

Hot partition mitigation: When a celebrity with 50M followers posts, the fan-out on read approach means followers query the celebrity’s posts table partition. Solve this by caching the celebrity’s recent posts in Redis and replicating across multiple read replicas.


Cross-Cutting Concerns

These topics apply to all three case studies and most system design problems:

Monitoring and Alerting

Every system needs metrics (latency p50/p95/p99, error rates, throughput), logs (structured, searchable), and alerts (PagerDuty, Slack). Use tools like Prometheus, Grafana, Datadog, and ELK stack.

Security

Authentication (OAuth 2.0, JWT), authorization (RBAC), input validation, rate limiting, encryption in transit (TLS) and at rest (AES-256), and protection against common attacks (SQL injection, XSS, CSRF).

Deployment and CI/CD

Blue-green deployments, canary releases, feature flags, automated testing pipelines, infrastructure as code (Terraform), and containerization (Docker, Kubernetes).

Data Privacy and Compliance

GDPR, CCPA, data retention policies, right to deletion, data anonymization, audit logging, and geographic data residency requirements.


Practice Tips

  1. Time yourself: Practice each case study in 35-40 minutes to simulate real interviews
  2. Draw diagrams: Always sketch the architecture before diving into details
  3. State trade-offs explicitly: β€œI chose X over Y because…”
  4. Start simple, then scale: Begin with a single-server design, then add complexity
  5. Ask clarifying questions: Do not assume requirements β€” ask
  6. Use real numbers: Back-of-the-envelope estimation drives design decisions
  7. Consider failure modes: What happens when each component fails?
  8. Practice explaining out loud: System design is as much about communication as technical knowledge