Skip to main content

Chapter 9: System Design Case Studies

"Design WhatsApp."

The interviewer leans back. You have 45 minutes. The whiteboard is empty. Your mind races through WebSockets, message queues, database schemas, read receipts, group chats, media storage, online presence — and then it hits you. Where do you even start?

This is the moment that separates the ₹30 LPA engineer from the ₹80 LPA one. Not because the ₹80 LPA engineer knows more technologies. Because they know how to structure the answer.

Most engineers walk into a system design interview like they're defusing a bomb — nervous, guessing, hoping they touch the right wire. The engineers who crack ₹60L+ walk in with a framework. They know the interviewer doesn't want a perfect architecture in 45 minutes. They want to see how you think.

This chapter gives you that framework, applied to five real systems that interviewers at Uber, Flipkart, Amazon India, and Google Bangalore actually ask. By the end, you'll have a repeatable system for any design question thrown at you.

The Framework: Six Steps, Every Time

Before we dive into the case studies, here's the structure you'll use for every single one. Memorize it. It's your anchor when your mind goes blank.

  1. Requirements — Functional and non-functional. Clarify scope. Ask questions.
  2. Estimation — Traffic, storage, bandwidth. Show you can do back-of-napkin math.
  3. Data Model — Tables, relationships, read/write patterns. This is where Node.js engineers shine.
  4. API Design — Endpoints, request/response shapes, protocols (REST, WebSocket, gRPC).
  5. Architecture — High-level diagram. Services, databases, caches, queues.
  6. Deep Dive + Trade-offs — Pick 2-3 components and go deep. Call out your choices.

That's it. Six steps. Every system. Let's prove it.


Case Study 1: URL Shortener (The Warm-Up)

This is the "FizzBuzz" of system design. Interviewers use it to check if you know the framework before they throw you into the deep end. Don't skip it — the patterns here repeat everywhere.

Requirements

You start by asking questions. This is not optional. Engineers who jump straight to the architecture fail this round.

"What's the scale? How many URLs per day?"

"100 million new URLs per month."

"Read-heavy or write-heavy?"

"Read-heavy. 100:1 read-to-write ratio."

"What's the short URL length?"

"7 characters. Alphanumeric."

"Custom aliases supported?"

"Yes, but optional."

"Expiry on links?"

"Default 5 years. Configurable."

Now you have a clear scope. Functional requirements: create short URL from long URL, redirect on access, optional custom alias, optional expiry. Non-functional: high availability, low latency on reads (< 50ms redirect), 100M writes/month, 10B reads/month.

Estimation

This is where you show you can do math. Indian interviewers at Flipkart and Uber love this part.

Writes: 100M/month = ~3.3M/day = ~40 writes/second.

Reads: 100:1 ratio = 10B reads/month = ~4,000 reads/second.

Storage: Each URL mapping is roughly 500 bytes (long URL ~200 bytes, short code ~7 bytes, metadata ~300 bytes). 100M/month × 500 bytes = 50 GB/month. Over 5 years: 50 GB × 60 months = 3 TB. Manageable.

Bandwidth: 4,000 reads/sec × 500 bytes = 2 MB/s incoming on writes, 2 MB/s outgoing on reads. Trivial.

Cache memory: If we cache the top 20% of URLs (the ones getting 80% of traffic), that's 20% of 6 billion URLs (5 years' worth) = 1.2B entries × 500 bytes = 600 GB. We'll need a distributed cache.

Data Model

Here's the table. Simple, but the choices matter.

CREATE TABLE urls (
short_code VARCHAR(7) PRIMARY KEY,
long_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP + INTERVAL '5 years'),
click_count BIGINT DEFAULT 0,
is_custom BOOLEAN DEFAULT FALSE
);

CREATE INDEX idx_expires_at ON urls(expires_at);
CREATE INDEX idx_user_id ON urls(user_id);

Why a VARCHAR primary key and not an auto-increment ID? Because the short code is the lookup key. Every redirect hits WHERE short_code = ?. Adding a surrogate key adds an unnecessary index lookup.

Why not use the short code as a hash of the long URL? Two reasons. First, hash collisions. Second, the same long URL might have different short codes for different users (analytics tracking). We generate the code independently.

The Short Code Generator

This is the heart of the system. You need a 7-character alphanumeric code that's unique and non-sequential (so users can't guess URLs).

// Base62 encoding: [a-z][A-Z][0-9] = 62 characters
const BASE62 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';

function toBase62(num) {
if (num === 0) return BASE62[0];
let result = '';
while (num > 0) {
result = BASE62[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}

// Distributed ID generator using Snowflake-style approach
// Each app server gets a unique worker ID (0-1023)
class IDGenerator {
constructor(workerId) {
this.workerId = workerId & 0x3FF; // 10 bits
this.sequence = 0;
this.lastTimestamp = -1;
this.epoch = 1700000000000n; // Custom epoch
}

generate() {
let timestamp = BigInt(Date.now()) - this.epoch;

if (timestamp === this.lastTimestamp) {
this.sequence = (this.sequence + 1) & 0xFFF; // 12 bits
if (this.sequence === 0) {
// Sequence exhausted, wait for next millisecond
while (BigInt(Date.now()) - this.epoch <= this.lastTimestamp) {}
timestamp = BigInt(Date.now()) - this.epoch;
}
} else {
this.sequence = 0;
}

this.lastTimestamp = timestamp;

// 42 bits timestamp | 10 bits worker | 12 bits sequence = 64 bits
const id = (timestamp << 22n) | (BigInt(this.workerId) << 12n) | BigInt(this.sequence);
return toBase62(Number(id));
}
}

const generator = new IDGenerator(1); // Worker ID from config
const shortCode = generator.generate(); // e.g., "3dK9mX2"

Why generate an ID first and then encode it, rather than generating random characters? Because random generation requires a database uniqueness check on every insert — a write amplification you can't afford at 40 writes/second. With a distributed ID generator, uniqueness is guaranteed at generation time. No database round-trip needed.

API Design

// POST /api/shorten
// Request
{
"long_url": "https://www.flipkart.com/some-very-long-product-url...",
"custom_alias": "my-deal", // optional
"expires_in_days": 365 // optional, default 1825
}

// Response 201
{
"short_url": "https://short.ly/3dK9mX2",
"long_url": "https://www.flipkart.com/...",
"expires_at": "2027-07-27T10:30:00Z"
}

// GET /:shortCode
// Response 302
// Location: https://www.flipkart.com/...

The redirect endpoint is where the read-heavy nature bites. 4,000 requests per second hitting the database directly will melt it. Here's the Node.js handler with caching:

const express = require('express');
const router = express.Router();

// Redis client for caching
const cache = require('../lib/cache');
const db = require('../lib/db');

router.get('/:shortCode', async (req, res) => {
const { shortCode } = req.params;

// 1. Check cache first — this handles 95%+ of requests
const cached = await cache.get(`url:${shortCode}`);
if (cached) {
// Async increment click count — don't block the redirect
cache.incr(`clicks:${shortCode}`);
return res.redirect(301, cached);
}

// 2. Cache miss — hit the database
const row = await db.query(
'SELECT long_url FROM urls WHERE short_code = $1 AND expires_at > NOW()',
[shortCode]
);

if (!row.rows.length) {
return res.status(404).json({ error: 'URL not found or expired' });
}

const longUrl = row.rows[0].long_url;

// 3. Populate cache with TTL
// TTL = min(remaining expiry time, 24 hours)
await cache.set(`url:${shortCode}`, longUrl, 'EX', 86400);

return res.redirect(301, longUrl);
});

Notice the 301 (permanent) redirect, not 302. Why? Because browsers cache 301s. If the same user clicks the same short link twice, the second request never hits your server. Free load shedding.

Architecture

Client → CDN → Load Balancer → API Servers (Node.js)

┌───────────────┼───────────────┐
▼ ▼ ▼
Redis Cache PostgreSQL ID Generator
(read path) (write path) (Snowflake)

┌─────┴─────┐
▼ ▼
ZooKeeper Worker ID
(coordinator) Registry

Deep Dive: Handling 4,000 Reads/Second

The database can handle maybe 1,000 reads/second on a single instance. You need to handle 4x that. Here's the layered strategy:

Layer 1: CDN. If the short URL is accessed via a browser, the 301 redirect gets cached by the browser itself. For API clients, put a CDN in front that caches 301 responses for 1 hour.

Layer 2: Redis cache. 600 GB of cache doesn't fit on one machine. You shard Redis across 10 nodes (60 GB each) using consistent hashing on the short code. When a node goes down, only 10% of the cache is cold — the rest still serves.

Layer 3: Database read replicas. For the cache misses, spread reads across 4 read replicas. The primary handles writes only.

Layer 4: Rate limiting. At 4,000 req/s, a single malicious client could DoS you. Rate limit by IP: 100 requests/minute for unauthenticated users.

// Rate limiter middleware
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');

const redirectLimiter = rateLimit({
store: new RedisStore({ client: cache }),
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute per IP
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
res.status(429).json({ error: 'Too many requests' });
}
});

router.get('/:shortCode', redirectLimiter, handleRedirect);

Bottlenecks and Trade-offs

Bottleneck 1: The ID generator is a single point of failure. If the Snowflake worker goes down, you can't create new short URLs. Solution: Run multiple workers with different worker IDs. If one dies, the others keep generating. The 10-bit worker ID gives you 1,024 workers — more than enough.

Bottleneck 2: Click count accuracy. Incrementing click counts on every redirect is expensive. The code above increments in Redis asynchronously and flushes to the database every 5 minutes. You lose at most 5 minutes of click data on a crash. Acceptable for analytics. Not acceptable for billing — but this isn't a billing system.

Trade-off: Short code length. 7 characters with Base62 gives you 62^7 = ~3.5 trillion unique codes. At 100M/month, that's 35,000 months = ~2,900 years before exhaustion. You could go to 6 characters (56 billion codes, 46 years) but 7 is safer and the extra character costs nothing in storage.

What the interviewer is really testing here: Can you identify the read-heavy pattern and design the caching strategy accordingly? Do you understand that the ID generation and the redirect serving are two completely different problems with different scaling characteristics?


Case Study 2: WhatsApp (Real-Time Chat)

The URL shortener was warm-up. Now the interviewer leans forward. "Design WhatsApp."

This is where most candidates panic. Real-time messaging touches WebSockets, message delivery guarantees, online presence, group chats, media storage, end-to-end encryption. It feels like everything at once.

Breathe. Same six steps.

Requirements

"What scale are we designing for?"

"500 million daily active users. 100 billion messages per day."

"One-on-one and group chat?"

"Both. Groups up to 1,024 members."

"Message delivery guarantees?"

"At-least-once delivery. Messages must not be lost."

"Media support?"

"Images, videos, documents. Max 100 MB per file."

"Read receipts? Online status?"

"Yes to both."

"Message history?"

"Permanent. Users can scroll back to the beginning."

Now you have scope. Functional: one-on-one messaging, group messaging, media sharing, online presence, read receipts, message history. Non-functional: 500M DAU, 100B messages/day, < 200ms message delivery, 99.99% availability, at-least-once delivery.

Estimation

Messages: 100B/day = ~1.15M messages/second at peak (assuming 70% of traffic in 16 waking hours).

Storage: Average message is 100 bytes of text. 100B × 100 bytes = 10 TB/day for text alone. Media is separate — assume 10% of messages have media, average 500 KB = 5 PB/day. Total: ~5 PB/day. Over a year: ~1.8 EB. This is massive.

Bandwidth: 1.15M msg/s × 100 bytes = 115 MB/s incoming. Outgoing: each message goes to at least 1 recipient, often more (group chats). Assume average 2 recipients = 230 MB/s outgoing. Media bandwidth is the real killer: 5 PB/day = ~58 GB/s.

Connections: 500M DAU, assume 20% active at peak = 100M concurrent WebSocket connections. Each server can handle ~50K connections. You need 2,000+ servers just for connection management.

Data Model

The key insight: chat data is append-only, time-ordered, and almost never updated. This screams for a wide-column store like Cassandra.

-- Messages table (Cassandra)
CREATE TABLE messages (
chat_id uuid,
message_id timeuuid,
sender_id bigint,
content text,
content_type text, -- 'text', 'image', 'video', 'document'
media_url text, -- S3/Blob storage URL
media_thumbnail text, -- Thumbnail for preview
created_at timestamp,
PRIMARY KEY (chat_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

-- Chat metadata (PostgreSQL — low volume, needs joins)
CREATE TABLE chats (
chat_id uuid PRIMARY KEY,
chat_type text, -- 'direct', 'group'
group_name text,
group_avatar text,
created_at timestamp,
last_message_at timestamp,
last_message_preview text
);

-- Chat members (PostgreSQL)
CREATE TABLE chat_members (
chat_id uuid,
user_id bigint,
joined_at timestamp,
last_read_message_id timeuuid,
is_admin boolean DEFAULT false,
PRIMARY KEY (chat_id, user_id)
);

Why Cassandra for messages? Because the access pattern is always "get the last N messages for a chat" — a range query on chat_id with message_id ordering. Cassandra's partition key (chat_id) and clustering key (message_id) are purpose-built for this. PostgreSQL would choke on 100B writes/day.

Why PostgreSQL for chat metadata? Because you need joins (get all chats for a user, with last message preview) and the volume is low — one row per chat, not per message.

API Design

Chat uses a hybrid protocol: WebSocket for real-time delivery, REST for historical data and media upload.

// WebSocket message format
// Client → Server
{
"type": "message",
"chat_id": "uuid",
"content": "Hey, are you coming to the meetup?",
"message_id": "client-generated-uuid", // Idempotency key
"timestamp": 1722067200000
}

// Server → Client (ack)
{
"type": "ack",
"message_id": "client-generated-uuid",
"server_message_id": "server-timeuuid",
"status": "delivered",
"timestamp": 1722067200123
}

// Server → Recipient (new message)
{
"type": "new_message",
"chat_id": "uuid",
"message_id": "server-timeuuid",
"sender_id": 12345,
"content": "Hey, are you coming to the meetup?",
"timestamp": 1722067200123
}

// REST: GET /api/chats/:chatId/messages?before=timeuuid&limit=50
// REST: POST /api/media/upload (multipart, returns media_url)
// REST: GET /api/users/:userId/presence

Notice the client generates the message ID. This is critical for exactly-once processing. If the client's WebSocket disconnects before receiving the ack, it retries with the same message ID. The server deduplicates by message ID.

Architecture

┌──────────────┐
│ CDN (Media) │
└──────┬───────┘

Client ←──WebSocket──→ Load Balancer

┌──────┴───────┐
│ Chat Servers │ (Node.js, 2000+ instances)
│ - WS mgmt │
│ - Auth │
│ - Routing │
└──────┬───────┘

┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌────────────┐ ┌──────────────┐
│ Kafka │ │ Presence │ │ Cassandra │
│ (msg Q) │ │ Service │ │ (messages) │
└────┬─────┘ │ (Redis) │ └──────────────┘
│ └────────────┘
┌────┴─────┐
▼ ▼
┌────────┐ ┌──────────┐
│ Msg │ │ Notification│
│ Store │ │ Service │
│ Workers│ │ (FCM/APNs) │
└────────┘ └──────────┘

Deep Dive: Message Delivery Flow

Here's the exact path a message takes from sender to recipient:

// Chat Server — WebSocket message handler
class ChatServer {
constructor() {
this.connections = new Map(); // userId → WebSocket
this.kafka = new KafkaProducer();
}

async handleMessage(ws, userId, payload) {
const { chat_id, content, message_id } = payload;

// 1. Idempotency check — have we already processed this?
const dedupeKey = `msg:${message_id}`;
const alreadyProcessed = await redis.get(dedupeKey);
if (alreadyProcessed) {
return this.sendAck(ws, message_id, alreadyProcessed);
}

// 2. Validate chat membership
const isMember = await this.validateMembership(userId, chat_id);
if (!isMember) {
return this.sendError(ws, 'Not a member of this chat');
}

// 3. Generate server message ID (timeuuid for ordering)
const serverMsgId = this.generateTimeUUID();

// 4. Write to Kafka — this is the source of truth
await this.kafka.send('chat-messages', {
chat_id,
message_id: serverMsgId,
client_message_id: message_id,
sender_id: userId,
content,
timestamp: Date.now()
});

// 5. Mark as processed (with TTL of 7 days)
await redis.set(dedupeKey, serverMsgId, 'EX', 604800);

// 6. Ack to sender immediately — message is durable in Kafka
this.sendAck(ws, message_id, serverMsgId);
}
}

The message is now in Kafka. A separate consumer writes it to Cassandra for persistence and routes it to the recipient:

// Message Delivery Worker
class MessageDeliveryWorker {
async process(message) {
const { chat_id, message_id, sender_id, content } = message;

// 1. Persist to Cassandra
await cassandra.execute(
'INSERT INTO messages (chat_id, message_id, sender_id, content, content_type, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[chat_id, message_id, sender_id, content, 'text', new Date()]
);

// 2. Get all recipients for this chat
const recipients = await this.getChatMembers(chat_id);

// 3. For each recipient, try WebSocket delivery first
for (const recipientId of recipients) {
if (recipientId === sender_id) continue; // Don't send to self

const connection = this.connectionRegistry.get(recipientId);
if (connection && connection.readyState === WebSocket.OPEN) {
connection.send(JSON.stringify({
type: 'new_message',
chat_id,
message_id,
sender_id,
content,
timestamp: Date.now()
}));
} else {
// 4. Offline — queue for push notification
await this.queuePushNotification(recipientId, {
chat_id,
sender_id,
content_preview: content.substring(0, 100)
});
}
}
}
}

Why Kafka between the chat server and the delivery worker? Decoupling. The chat server's job is to accept the message and ack the sender fast. It should never block on database writes or recipient lookups. Kafka buffers the message durably. Even if the delivery workers are slow, the sender gets an instant ack.

Online Presence

Presence is the hardest part of any chat system. Every status change (online, offline, last seen) must propagate to all contacts who have the user's chat open. At 500M DAU, that's a firehose of status changes.

The trick: don't broadcast every status change. Use a heartbeat and only notify on state transitions.

// Presence Service
class PresenceService {
constructor() {
// Redis: user_id → { status, last_heartbeat }
this.redis = new Redis();
// Pub/sub channel for presence changes
this.pubsub = new Redis();
}

async heartbeat(userId) {
const key = `presence:${userId}`;
const previous = await this.redis.get(key);
const previousStatus = previous ? JSON.parse(previous).status : 'offline';

// Update heartbeat with 30-second TTL
// If no heartbeat for 30s, Redis key expires → user is offline
await this.redis.set(key, JSON.stringify({
status: 'online',
last_heartbeat: Date.now()
}), 'EX', 30);

// Only publish on state transition
if (previousStatus !== 'online') {
await this.pubsub.publish('presence-changes', JSON.stringify({
user_id: userId,
status: 'online',
timestamp: Date.now()
}));
}
}

async getPresence(userIds) {
const pipeline = this.redis.pipeline();
for (const id of userIds) {
pipeline.get(`presence:${id}`);
}
const results = await pipeline.exec();
return userIds.map((id, i) => ({
user_id: id,
status: results[i][1] ? 'online' : 'offline',
last_seen: results[i][1]
? JSON.parse(results[i][1]).last_heartbeat
: null
}));
}
}

The 30-second TTL on the heartbeat key is the elegant part. If the user's app crashes or loses connectivity, the key expires automatically. No cleanup job needed. Redis does the work.

Bottlenecks and Trade-offs

Bottleneck 1: Group chat fan-out. A message to a 1,024-member group requires 1,023 deliveries. At 1.15M messages/second, even a small fraction being group messages creates massive fan-out. Solution: Use a message queue per recipient shard. Each delivery worker handles a subset of users. The Kafka consumer writes to 1,023 per-user queues, and delivery workers pull from their assigned queues.

Bottleneck 2: Media storage costs. 5 PB/day is not cheap. Solution: Deduplication. If the same viral video is shared 10,000 times, store it once. Use content-addressable storage (hash the file, use the hash as the key). The media_url in the message points to the same blob for all shares.

Trade-off: Message ordering vs. delivery speed. Cassandra orders messages by message_id (timeuuid) within a chat. But if two messages arrive at Kafka out of order (rare but possible), they'll be stored out of order. For a chat app, this is acceptable — the user sees "delivered" and the slight reorder is invisible. For a trading system, it would be unacceptable. Know the difference.

What the interviewer is testing: Can you separate the real-time path (WebSocket → Kafka → delivery) from the persistence path (Kafka → Cassandra)? Do you understand that presence is a state machine, not a broadcast? Can you estimate the fan-out problem for group chats?


The Pattern Is Emerging

Stop for a moment. Look at what you've done so far.

The URL shortener taught you: read-heavy caching, distributed ID generation, layered defense against load.

WhatsApp taught you: real-time delivery with message queues, presence as a state machine, fan-out for group messaging, append-only data modeling with Cassandra.

Two completely different systems. Same six-step framework. Same building blocks: load balancers, caches, databases, message queues, CDNs.

You're not learning five systems. You're learning one framework applied five ways. Let that sink in.


Case Study 3: Netflix (Video Streaming)

"Design a video streaming platform like Netflix."

This one tests whether you understand that streaming is fundamentally a distribution problem, not a playback problem. The hard part isn't playing a video. It's getting that video to 200 million people across 190 countries, on connections ranging from 4G in Mumbai to fiber in Bangalore, without buffering.

Requirements

"Global or India-only?"

"Global. 200 million subscribers."

"Content library size?"

"10,000 titles. Each title has multiple resolutions."

"Live streaming or VOD?"

"Video on demand only. No live."

"User-generated content or professional?"

"Professional. We control the encoding pipeline."

"Concurrent viewers at peak?"

"50 million."

Functional: browse catalog, search, play video with adaptive bitrate, resume playback, recommendations. Non-functional: 200M subscribers, 50M concurrent, < 2s start-up time, zero buffering on stable connections, 99.99% availability.

Estimation

Storage: 10,000 titles. Average movie: 2 hours. At 4K (15 Mbps): 2h × 3600s × 15 Mbps / 8 = 13.5 GB per title per resolution. With 5 resolutions (240p, 480p, 720p, 1080p, 4K): ~50 GB per title. 10,000 × 50 GB = 500 TB. Plus thumbnails, metadata, subtitles: ~600 TB total. Surprisingly small — storage is not the bottleneck.

Bandwidth: 50M concurrent × average 5 Mbps = 250 Tbps. This is the real problem. 250 Tbps is roughly 31 TB/s. No single data center can serve this. You need a CDN.

Transcoding: New content arrives as a master file (50-100 GB). Transcoding to 5 resolutions takes ~2x real-time on GPU instances. A 2-hour movie takes 4 hours to transcode. For 10,000 titles, you need a farm of transcoding workers.

Data Model

-- Content metadata (PostgreSQL — low volume, complex queries)
CREATE TABLE titles (
title_id uuid PRIMARY KEY,
title text NOT NULL,
description text,
genre text[],
release_year int,
maturity_rating text,
avg_rating decimal(3,2),
created_at timestamp
);

-- Video assets (one title → many resolutions)
CREATE TABLE video_assets (
asset_id uuid PRIMARY KEY,
title_id uuid REFERENCES titles(title_id),
resolution text, -- '240p', '480p', '720p', '1080p', '4K'
bitrate int, -- in kbps
codec text, -- 'h264', 'h265', 'av1'
manifest_url text, -- HLS/DASH manifest file URL
cdn_base_url text, -- CDN prefix for segments
duration_seconds int,
file_size_bytes bigint,
created_at timestamp
);

-- User watch history (Cassandra — high write volume)
CREATE TABLE watch_history (
user_id bigint,
title_id uuid,
last_position_seconds int,
last_watched_at timestamp,
completed boolean,
PRIMARY KEY (user_id, title_id)
);

API Design

// GET /api/titles?genre=action&page=1&limit=20
// Response: paginated list of titles with thumbnails

// GET /api/titles/:titleId
// Response: full metadata, available resolutions, cast, similar titles

// GET /api/titles/:titleId/playback
// Response:
{
"manifest_url": "https://cdn.netflix.com/manifests/title-123.m3u8",
"drm_license_url": "https://drm.netflix.com/license",
"available_resolutions": ["240p", "480p", "720p", "1080p", "4K"],
"subtitles": [
{ "language": "en", "url": "https://cdn.netflix.com/subs/title-123-en.vtt" },
{ "language": "hi", "url": "https://cdn.netflix.com/subs/title-123-hi.vtt" }
]
}

// POST /api/titles/:titleId/progress
// Request: { "position_seconds": 1234 }
// Called every 30 seconds during playback

Architecture

Client (Smart TV / Mobile / Browser)

│ 1. Browse/Search → API Servers (Node.js) → PostgreSQL
│ 2. Playback request → API Servers → Manifest URL
│ 3. Video segments → CDN (Akamai / CloudFront / Own CDN)


┌─────────────────────────────────────────────────┐
│ CDN Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Mumbai │ │ Delhi │ │Singapore │ ... │
│ │ PoP │ │ PoP │ │ PoP │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ▲ ▲ ▲ │
│ │ │ │ │
│ ┌────┴──────────────┴──────────────┴────┐ │
│ │ Origin Servers │ │
│ │ (S3 / Object Storage) │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘

│ Video segments (pre-transcoded)

┌───┴──────────────────────────────────────────────┐
│ Transcoding Pipeline │
│ │
│ Master File → Chunk Splitter → Encoder Farm │
│ (S3) (Lambda) (GPU Instances) │
│ │ │
│ ┌─────┴─────┐ │
│ ▼ ▼ │
│ S3 (segments) Manifest │
└───────────────────────────────────────────────────┘

Deep Dive: Adaptive Bitrate Streaming

This is the core technology that makes Netflix work on everything from a ₹6,000 Android phone on Jio 4G to a ₹2 lakh OLED TV on Airtel fiber.

The video is split into 2-10 second segments. Each segment is encoded at 5 different bitrates. The player dynamically switches between bitrates based on network conditions.

// Simplified HLS manifest (.m3u8) generator
class ManifestGenerator {
generateMasterManifest(titleId, assets) {
let manifest = '#EXTM3U\n#EXT-X-VERSION:3\n';

for (const asset of assets) {
const bandwidth = asset.bitrate * 1000; // Convert to bps
const resolution = this.getResolutionDimensions(asset.resolution);

manifest += `#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},` +
`RESOLUTION=${resolution.width}x${resolution.height},` +
`CODECS="${asset.codec}"\n`;
manifest += `${asset.manifest_url}\n`;
}

return manifest;
}

getResolutionDimensions(resolution) {
const map = {
'240p': { width: 426, height: 240 },
'480p': { width: 854, height: 480 },
'720p': { width: 1280, height: 720 },
'1080p': { width: 1920, height: 1080 },
'4K': { width: 3840, height: 2160 }
};
return map[resolution];
}
}

The player's job is to monitor the buffer and switch bitrates before the buffer empties:

// Adaptive Bitrate Algorithm (simplified ABR logic in the player)
class ABRController {
constructor() {
this.bufferLevel = 0; // seconds of video buffered
this.currentBitrate = null;
this.throughputEstimate = 0; // bps
this.segmentHistory = []; // last N segment download stats
}

onSegmentDownloaded(segmentBitrate, downloadTimeMs, segmentDurationMs) {
// Update throughput estimate using exponential moving average
const throughput = (segmentBitrate * segmentDurationMs) / downloadTimeMs;
const alpha = 0.3; // Smoothing factor
this.throughputEstimate = alpha * throughput +
(1 - alpha) * this.throughputEstimate;

this.segmentHistory.push({ bitrate: segmentBitrate, time: downloadTimeMs });
if (this.segmentHistory.length > 10) this.segmentHistory.shift();
}

selectNextBitrate(availableBitrates) {
// Rule 1: If buffer is critically low, drop to lowest bitrate
if (this.bufferLevel < 5) {
return availableBitrates[0]; // Lowest
}

// Rule 2: If buffer is healthy, use 90% of estimated throughput
// (10% headroom for network variance)
const safeThroughput = this.throughputEstimate * 0.9;

// Rule 3: Don't switch up more than one level at a time
const currentIndex = availableBitrates.indexOf(this.currentBitrate);
const maxNextIndex = Math.min(
currentIndex + 1,
availableBitrates.length - 1
);

// Pick the highest bitrate that fits within safe throughput
for (let i = maxNextIndex; i >= 0; i--) {
if (availableBitrates[i] <= safeThroughput) {
return availableBitrates[i];
}
}

return availableBitrates[0]; // Fallback to lowest
}
}

Three rules govern the ABR algorithm. Rule 1: survival first — if the buffer is low, drop quality immediately. Rule 2: use 90% of estimated throughput, leaving 10% headroom for network jitter. Rule 3: never jump more than one quality level at a time — gradual improvement feels smooth; sudden jumps feel broken.

The CDN Problem: Getting Video to India

India is Netflix's hardest market. Not because of subscriber count — because of infrastructure. A user in Leh and a user in Chennai are watching the same show, but their network paths are completely different.

Netflix solved this with Open Connect — their own CDN. They place caching appliances inside ISP data centers. When a user in Mumbai on Jio Fiber requests a video, it's served from a box inside Jio's own data center, not from AWS us-east-1.

For your system design interview, you don't need to propose building a CDN. You need to show you understand the problem:

// CDN routing logic — which edge server serves this user?
class CDNRouter {
routeUserToEdge(userIp, titleId) {
// 1. GeoIP lookup — which region is the user in?
const region = this.geoIpLookup(userIp);
// e.g., { city: 'Mumbai', isp: 'Jio', lat: 19.076, lon: 72.877 }

// 2. Find the nearest edge PoP with the content
const edges = this.getEdgePoPs(region);

// 3. Check content availability at each edge
for (const edge of edges) {
if (this.hasContent(edge, titleId)) {
return edge;
}
}

// 4. Cache miss — pull from origin, serve from nearest edge
const nearestEdge = edges[0];
this.prefetchContent(nearestEdge, titleId);
return nearestEdge; // Serve with higher latency this time
}
}

Bottlenecks and Trade-offs

Bottleneck 1: Cold start on new content. When Stranger Things drops a new season, 50 million people hit play within hours. No CDN edge has the content cached. Solution: Pre-warm the CDN. Push the first 5 minutes of each episode to all edge nodes before the release time. The rest fills on demand.

Bottleneck 2: Transcoding pipeline throughput. A single GPU instance transcodes a 2-hour movie in 4 hours. For 10,000 titles, that's 40,000 GPU-hours. Solution: Parallelize by segment. Split the master file into 5-minute chunks, transcode each chunk on a separate GPU instance, reassemble. A 2-hour movie becomes 24 parallel jobs, finishing in ~10 minutes.

Trade-off: Storage vs. compute. You could store every resolution of every title (500 TB) or transcode on-the-fly. Netflix stores everything — storage is cheap, compute is expensive, and latency matters. YouTube (user-generated, long tail) transcodes on-demand for rarely-watched videos. Know which trade-off applies to your system.

What the interviewer is testing: Do you understand that streaming is a distribution problem, not a playback problem? Can you explain adaptive bitrate? Do you know why a CDN is non-negotiable for video at scale?


Case Study 4: Uber (Ride Sharing)

"Design Uber."

This is the geospatial system design question. It tests whether you understand that the hard part isn't booking a ride — it's finding the right driver among thousands, in real-time, as both driver and rider positions change every few seconds.

Requirements

"Which market?"

"India. Top 10 cities. 50 million rides per day."

"Rider app and driver app?"

"Both. Rider requests rides. Driver accepts and completes."

"Real-time tracking?"

"Yes. Rider sees driver location updated every 3 seconds."

"Pricing?"

"Dynamic pricing based on demand, distance, and time."

"Matching?"

"Nearest available driver. Within 2 km radius."

Functional: rider requests ride, driver accepts, real-time GPS tracking, dynamic pricing, ride history, ratings. Non-functional: 50M rides/day, < 5s matching time, 99.9% matching success rate, GPS updates every 3 seconds, 50M concurrent GPS pings.

Estimation

Rides: 50M/day = ~580 rides/second at average, ~2,000 rides/second at peak (Friday 7 PM in Bangalore).

GPS pings: Each active driver pings every 3 seconds. Assume 2M active drivers at peak. 2M / 3 = ~670,000 GPS updates/second.

Storage: Each ride record: ~2 KB (pickup, dropoff, route, fare, ratings). 50M × 2 KB = 100 GB/day. 36.5 TB/year. Manageable.

Matching QPS: 2,000 ride requests/second. Each request queries drivers within a 2 km radius. This is a geospatial query at 2,000 QPS.

Data Model

-- Drivers (PostgreSQL — needs transactional consistency)
CREATE TABLE drivers (
driver_id bigint PRIMARY KEY,
name text NOT NULL,
phone text UNIQUE,
vehicle_type text, -- 'auto', 'mini', 'sedan', 'suv'
vehicle_number text,
status text DEFAULT 'offline', -- 'offline', 'online', 'on_ride'
rating decimal(3,2),
total_rides int DEFAULT 0,
created_at timestamp
);

-- Driver location (Redis — ephemeral, high write)
-- Key: driver:loc:{driver_id}
-- Value: { lat: 12.9716, lng: 77.5946, geohash: "tdr1v", updated_at: 1722067200 }
-- TTL: 30 seconds (stale locations are useless)

-- Rides (Cassandra — append-only, high volume)
CREATE TABLE rides (
ride_id uuid PRIMARY KEY,
rider_id bigint,
driver_id bigint,
status text, -- 'requested', 'accepted', 'arrived', 'started', 'completed', 'cancelled'
pickup_lat decimal(10,7),
pickup_lng decimal(10,7),
pickup_address text,
dropoff_lat decimal(10,7),
dropoff_lng decimal(10,7),
dropoff_address text,
fare_estimate decimal(10,2),
fare_final decimal(10,2),
requested_at timestamp,
accepted_at timestamp,
started_at timestamp,
completed_at timestamp
);

-- Ride route points (Cassandra — time-series GPS trail)
CREATE TABLE ride_route (
ride_id uuid,
timestamp timestamp,
lat decimal(10,7),
lng decimal(10,7),
speed decimal(5,2), -- km/h
bearing decimal(5,2), -- degrees
PRIMARY KEY (ride_id, timestamp)
);

API Design

// Rider App APIs
// POST /api/rides/request
// Request: { pickup_lat, pickup_lng, dropoff_lat, dropoff_lng, vehicle_type }
// Response: { ride_id, status: 'searching' }

// GET /api/rides/:rideId (polled every 2s until driver assigned)
// Response: { ride_id, status, driver: { name, vehicle, rating, lat, lng, eta } }

// Driver App APIs
// POST /api/drivers/location (called every 3 seconds)
// Request: { driver_id, lat, lng, bearing, speed }

// POST /api/rides/:rideId/accept
// POST /api/rides/:rideId/start
// POST /api/rides/:rideId/complete

Architecture

Rider App Driver App
│ │
│ REST + WebSocket │ WebSocket (GPS stream)
▼ ▼
┌─────────────────────────────────────────┐
│ API Gateway │
│ (auth, rate limiting, routing) │
└──────────────────┬──────────────────────┘

┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌───────────┐
│ Ride │ │ Matching │ │ Pricing │
│ Service │ │ Engine │ │ Engine │
└────┬────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ ┌──────┐ ┌──────────┐ │
│ │Redis │ │ Geohash │ │
│ │(locs)│ │ Index │ │
│ └──────┘ └──────────┘ │
│ │ │
└────────────┼─────────────┘

┌──────────────┐
│ Kafka │
│ (ride events)│
└──────┬───────┘

┌──────────────┐
│ Cassandra │
│ (rides, route)│
└──────────────┘

Deep Dive: Geospatial Matching

This is the heart of Uber. Given a rider's location, find the nearest available drivers within 2 km. At 2,000 requests/second with 2 million active drivers, a naive approach (calculate distance to every driver) is 2,000 × 2M = 4 billion distance calculations per second. Impossible.

The solution: Geohash.

A Geohash encodes a latitude/longitude pair into a string. Adjacent locations share the same prefix. "tdr1v" is roughly a 5 km × 5 km area in Bangalore. "tdr1" is a larger area. "tdr1v0" is smaller.

// Geohash-based driver search
class DriverMatcher {
constructor() {
this.redis = new Redis();
}

// Find available drivers near a pickup point
async findNearbyDrivers(lat, lng, radiusKm = 2) {
// 1. Compute geohash for the pickup point
// Precision 6 = ~1.2 km × 0.6 km cell
const geohash = this.encodeGeohash(lat, lng, 6);

// 2. Get the geohash and its 8 neighbors
// This covers the pickup point plus all adjacent cells
const neighbors = this.getNeighbors(geohash);
const searchCells = [geohash, ...neighbors];

// 3. Fetch all drivers in these cells from Redis
// Redis key: drivers:cell:{geohash} → Set of driver IDs
const pipeline = this.redis.pipeline();
for (const cell of searchCells) {
pipeline.smembers(`drivers:cell:${cell}`);
}
const results = await pipeline.exec();

// 4. Collect all candidate driver IDs
const candidateIds = new Set();
for (const [, members] of results) {
for (const id of members) {
candidateIds.add(id);
}
}

// 5. Fetch exact locations for candidates
const driverLocs = await this.batchGetDriverLocations([...candidateIds]);

// 6. Filter by exact distance (Haversine) and availability
const nearby = [];
for (const driver of driverLocs) {
const distance = this.haversineDistance(
lat, lng, driver.lat, driver.lng
);
if (distance <= radiusKm && driver.status === 'online') {
nearby.push({ ...driver, distance });
}
}

// 7. Sort by distance, return top 10
nearby.sort((a, b) => a.distance - b.distance);
return nearby.slice(0, 10);
}

// Haversine formula — distance between two lat/lng points
haversineDistance(lat1, lng1, lat2, lng2) {
const R = 6371; // Earth's radius in km
const dLat = this.toRad(lat2 - lat1);
const dLng = this.toRad(lng2 - lng1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}

toRad(deg) { return deg * (Math.PI / 180); }
}

The key insight: Geohash reduces the search space from 2 million drivers to maybe 200 candidates (drivers in the 9 adjacent cells). Then you do exact Haversine distance on only those 200. The cost drops from 4 billion calculations/second to 200 × 2,000 = 400,000/second. A 10,000x improvement.

When a driver's location updates, you update their geohash cell in Redis:

// Driver location update handler
async function updateDriverLocation(driverId, lat, lng, status) {
const newGeohash = encodeGeohash(lat, lng, 6);
const key = `driver:loc:${driverId}`;

// Get previous geohash to clean up old cell
const prev = await redis.get(key);
const prevData = prev ? JSON.parse(prev) : null;

if (prevData && prevData.geohash !== newGeohash) {
// Driver moved to a new cell — remove from old, add to new
await redis.srem(`drivers:cell:${prevData.geohash}`, driverId);
await redis.sadd(`drivers:cell:${newGeohash}`, driverId);
} else if (!prevData) {
// First location ping — just add
await redis.sadd(`drivers:cell:${newGeohash}`, driverId);
}

// Update location with 30s TTL
await redis.set(key, JSON.stringify({
lat, lng, geohash: newGeohash, status, updated_at: Date.now()
}), 'EX', 30);
}

Dynamic Pricing (Surge)

Pricing is a separate service. It reads demand (open ride requests per geohash cell) and supply (available drivers per cell) and computes a multiplier.

// Simplified surge pricing engine
class PricingEngine {
async calculateFare(pickupLat, pickupLng, dropoffLat, dropoffLng, vehicleType) {
// 1. Base fare by vehicle type
const baseFare = {
'auto': 25,
'mini': 50,
'sedan': 80,
'suv': 120
}[vehicleType];

// 2. Distance and time estimate (call Maps API)
const route = await this.mapsApi.estimateRoute(
pickupLat, pickupLng, dropoffLat, dropoffLng
);

const distanceFare = route.distanceKm * 12; // ₹12/km
const timeFare = route.durationMin * 0.5; // ₹0.5/min

// 3. Surge multiplier based on local demand/supply
const geohash = this.encodeGeohash(pickupLat, pickupLng, 5);
const demand = await this.getOpenRequestsInCell(geohash);
const supply = await this.getAvailableDriversInCell(geohash);

let surgeMultiplier = 1.0;
if (supply > 0) {
const ratio = demand / supply;
// Surge kicks in at 1.2:1, caps at 3.0x
surgeMultiplier = Math.min(3.0, Math.max(1.0, ratio * 0.8));
}

// 4. Total fare
const subtotal = baseFare + distanceFare + timeFare;
const total = Math.round(subtotal * surgeMultiplier);

return {
estimate: total,
breakdown: { baseFare, distanceFare, timeFare, surgeMultiplier },
route: { distanceKm: route.distanceKm, durationMin: route.durationMin }
};
}
}

Bottlenecks and Trade-offs

Bottleneck 1: GPS update volume. 670,000 GPS updates/second is a firehose. Each update writes to Redis (location) and potentially updates the geohash index. Solution: Batch updates. The driver app sends location every 3 seconds, but the matching service only needs ~10-second accuracy. Buffer updates for 1 second and write in batches of ~200K.

Bottleneck 2: The matching engine is CPU-bound. Haversine calculations on 200 candidates per request, at 2,000 requests/second, is 400,000 Haversine calculations/second. Each Haversine involves 6 trig functions. Solution: Pre-filter with a bounding box (cheap — just lat/lng comparisons) before running Haversine. Eliminates 80% of candidates with simple arithmetic.

Trade-off: Consistency vs. availability for driver location. If Redis goes down, you lose all driver locations. Riders can't find drivers. This is a hard availability requirement. Solution: Use a Redis cluster with replication. If the primary fails, the replica takes over in < 1 second. You might serve a 3-second-stale location, but that's better than no location at all.

What the interviewer is testing: Do you reach for Geohash immediately, or do you fumble with "query all drivers and calculate distance"? Can you explain why the geospatial index is the core of the system? Do you understand that the matching engine and the pricing engine are separate services with different scaling characteristics?


You're Building a Toolkit

Four systems in. Let's check what you've collected:

  • URL Shortener: Caching layers, distributed ID generation, read-heavy optimization
  • WhatsApp: Message queues for decoupling, presence as state machine, append-only data modeling
  • Netflix: CDN distribution, adaptive bitrate, transcoding pipelines
  • Uber: Geospatial indexing, real-time matching, dynamic pricing

Each system added 2-3 patterns to your toolkit. The fifth system will add the hardest one: consistency under extreme concurrency.


Case Study 5: Stock Exchange (Trading Platform)

"Design a stock trading platform."

This is the consistency and concurrency gauntlet. Unlike the previous systems where eventual consistency was acceptable, a trading platform has hard correctness requirements. Two people cannot buy the same share. Money cannot be created or destroyed. Orders must execute in the correct sequence.

Requirements

"Which market?"

"NSE India. Equity delivery and intraday."

"Order types?"

"Market orders, limit orders, stop-loss."

"Throughput?"

"10 million orders per day. Peak: 50,000 orders/second during market open (9:15 AM)."

"Latency requirements?"

"Order acknowledgment: < 10ms. Order matching: < 1ms. Market data: < 5ms."

"Consistency?"

"Strong consistency. No double-spending. No lost orders."

Functional: place order (market, limit, stop-loss), cancel order, view order book, view portfolio, view market data, execute trades. Non-functional: 50K orders/sec peak, < 10ms order ack, < 1ms matching, strong consistency, 99.999% uptime during market hours (9:15 AM - 3:30 PM).

Estimation

Orders: 10M/day. Peak: 50,000/sec for the first 5 minutes of trading. Average: ~500/sec during the rest of the day.

Order book depth: NSE has ~1,700 listed stocks. Each stock has an order book with buy and sell sides. Average 500 open orders per stock = 850,000 open orders at any time.

Storage: Each order: ~200 bytes. 10M × 200 bytes = 2 GB/day. Each trade: ~300 bytes. Assume 50% fill rate = 5M trades × 300 bytes = 1.5 GB/day. Total: ~3.5 GB/day. Storage is trivial.

The real constraint is latency, not throughput. 50,000 orders/second is manageable with the right architecture. 1ms matching latency is the hard part.

Data Model

-- Orders (in-memory + periodic flush to PostgreSQL)
-- The order book lives entirely in memory during trading hours
CREATE TABLE orders (
order_id bigint PRIMARY KEY,
user_id bigint NOT NULL,
symbol text NOT NULL, -- 'RELIANCE', 'TCS', 'INFY'
order_type text NOT NULL, -- 'MARKET', 'LIMIT', 'STOP_LOSS'
side text NOT NULL, -- 'BUY', 'SELL'
quantity int NOT NULL,
price decimal(10,2), -- NULL for market orders
trigger_price decimal(10,2), -- For stop-loss orders
status text DEFAULT 'OPEN', -- 'OPEN', 'PARTIAL', 'FILLED', 'CANCELLED'
filled_quantity int DEFAULT 0,
created_at timestamp DEFAULT NOW()
);

-- Trades (immutable, append-only)
CREATE TABLE trades (
trade_id bigint PRIMARY KEY,
buy_order_id bigint REFERENCES orders(order_id),
sell_order_id bigint REFERENCES orders(order_id),
symbol text NOT NULL,
quantity int NOT NULL,
price decimal(10,2) NOT NULL,
executed_at timestamp DEFAULT NOW()
);

-- User portfolios (PostgreSQL — needs ACID)
CREATE TABLE portfolios (
user_id bigint,
symbol text,
quantity int NOT NULL,
average_price decimal(10,2),
PRIMARY KEY (user_id, symbol)
);

-- User balances (PostgreSQL — needs ACID)
CREATE TABLE balances (
user_id bigint PRIMARY KEY,
available_cash decimal(15,2) NOT NULL,
blocked_cash decimal(15,2) DEFAULT 0 -- Funds blocked for open orders
);

API Design

// POST /api/orders
// Request:
{
"symbol": "RELIANCE",
"side": "BUY",
"order_type": "LIMIT",
"quantity": 100,
"price": 2450.00
}
// Response 201:
{
"order_id": 987654321,
"status": "OPEN",
"filled_quantity": 0,
"created_at": "2026-07-27T09:15:00.123Z"
}

// GET /api/orders/:orderId
// Response: order status, filled quantity, average fill price

// DELETE /api/orders/:orderId (cancel)
// Response: { order_id, status: 'CANCELLED' }

// GET /api/market/orderbook?symbol=RELIANCE&depth=20
// Response: top 20 bid/ask levels

// WebSocket: /ws/market-data?symbols=RELIANCE,TCS,INFY
// Stream: real-time price ticks, order book updates

Architecture

Client (Trading App)

│ WebSocket (market data) + REST (orders)

┌───────────────────┐
│ API Gateway │
│ (auth, rate │
│ limiting) │
└────────┬──────────┘

┌────┴────┐
▼ ▼
┌────────┐ ┌──────────────┐
│ Order │ │ Market Data │
│ Gateway│ │ Service │
└───┬────┘ └──────────────┘

│ Order validation (balance check, position check)


┌──────────────────────────────┐
│ Matching Engine │
│ ┌────────────────────────┐ │
│ │ Order Book (in-memory) │ │
│ │ - Price-time priority │ │
│ │ - Per-symbol books │ │
│ └────────────────────────┘ │
│ ┌────────────────────────┐ │
│ │ Trade Executor │ │
│ │ - Atomic settlement │ │
│ └────────────────────────┘ │
└──────────┬───────────────────┘

┌─────┴─────┐
▼ ▼
┌─────────┐ ┌──────────┐
│ Kafka │ │PostgreSQL│
│(events) │ │(balances,│
│ │ │portfolio)│
└────┬────┘ └──────────┘


┌──────────────┐
│ Event Store │
│ (Cassandra) │
└──────────────┘

Deep Dive: The Matching Engine

The matching engine is the heart of the exchange. It maintains an in-memory order book for each stock and matches buy orders against sell orders using price-time priority.

// In-memory order book with price-time priority
class OrderBook {
constructor(symbol) {
this.symbol = symbol;
// Sorted sets: price → queue of orders at that price
// Bids (buys): highest price first
this.bids = new Map(); // price → Order[]
this.bidPrices = []; // sorted descending
// Asks (sells): lowest price first
this.asks = new Map(); // price → Order[]
this.askPrices = []; // sorted ascending
}

addOrder(order) {
const book = order.side === 'BUY' ? this.bids : this.asks;
const prices = order.side === 'BUY' ? this.bidPrices : this.askPrices;

if (!book.has(order.price)) {
book.set(order.price, []);
// Insert price in sorted position
this.insertSorted(prices, order.price, order.side === 'BUY');
}

// Orders at the same price are FIFO (time priority)
book.get(order.price).push(order);
}

// Match an incoming order against the book
match(order) {
const trades = [];
let remainingQty = order.quantity;

if (order.side === 'BUY') {
// Buy order matches against asks (lowest first)
for (const askPrice of this.askPrices) {
// For limit orders, only match if price is acceptable
if (order.order_type === 'LIMIT' && askPrice > order.price) break;

const askOrders = this.asks.get(askPrice);
while (askOrders.length > 0 && remainingQty > 0) {
const askOrder = askOrders[0];
const matchQty = Math.min(remainingQty, askOrder.quantity);

trades.push({
buy_order_id: order.order_id,
sell_order_id: askOrder.order_id,
symbol: this.symbol,
quantity: matchQty,
price: askPrice, // Trade at the ask price (the resting order's price)
});

remainingQty -= matchQty;
askOrder.quantity -= matchQty;

if (askOrder.quantity === 0) {
askOrders.shift(); // Remove fully filled order
}
}

// Clean up empty price levels
if (askOrders.length === 0) {
this.asks.delete(askPrice);
this.askPrices = this.askPrices.filter(p => p !== askPrice);
}

if (remainingQty === 0) break;
}
} else {
// Sell order matches against bids (highest first)
for (const bidPrice of this.bidPrices) {
if (order.order_type === 'LIMIT' && bidPrice < order.price) break;

const bidOrders = this.bids.get(bidPrice);
while (bidOrders.length > 0 && remainingQty > 0) {
const bidOrder = bidOrders[0];
const matchQty = Math.min(remainingQty, bidOrder.quantity);

trades.push({
buy_order_id: bidOrder.order_id,
sell_order_id: order.order_id,
symbol: this.symbol,
quantity: matchQty,
price: bidPrice,
});

remainingQty -= matchQty;
bidOrder.quantity -= matchQty;

if (bidOrder.quantity === 0) {
bidOrders.shift();
}
}

if (bidOrders.length === 0) {
this.bids.delete(bidPrice);
this.bidPrices = this.bidPrices.filter(p => p !== bidPrice);
}

if (remainingQty === 0) break;
}
}

// If order is not fully filled, add remainder to the book
if (remainingQty > 0 && order.order_type === 'LIMIT') {
order.quantity = remainingQty;
this.addOrder(order);
}

return trades;
}

insertSorted(arr, value, descending) {
let i = 0;
while (i < arr.length &&
(descending ? arr[i] > value : arr[i] < value)) {
i++;
}
arr.splice(i, 0, value);
}
}

The matching engine runs single-threaded per symbol. This is intentional. Single-threaded means no locks, no race conditions, no concurrent modification of the order book. At 50,000 orders/second across 1,700 symbols, that's ~30 orders/second per symbol. A single thread can handle thousands per second per symbol.

The Consistency Problem

Here's the hard part. When a trade executes, three things must happen atomically:

  1. Update the buyer's balance (debit cash)
  2. Update the seller's balance (credit cash)
  3. Update both portfolios (transfer shares)

If any of these fail, you have an inconsistency. Money created or destroyed. Shares duplicated.

// Atomic trade settlement
class TradeSettlement {
async settleTrade(trade) {
const { buy_order_id, sell_order_id, symbol, quantity, price } = trade;
const totalValue = quantity * price;

// Use a database transaction — all or nothing
const client = await db.pool.connect();

try {
await client.query('BEGIN');

// 1. Debit buyer's cash
const buyerResult = await client.query(
`UPDATE balances
SET available_cash = available_cash - $1,
blocked_cash = blocked_cash - $1
WHERE user_id = (SELECT user_id FROM orders WHERE order_id = $2)
AND available_cash >= $1
RETURNING user_id`,
[totalValue, buy_order_id]
);

if (buyerResult.rows.length === 0) {
throw new Error('Insufficient buyer balance');
}

// 2. Credit seller's cash
await client.query(
`UPDATE balances
SET available_cash = available_cash + $1
WHERE user_id = (SELECT user_id FROM orders WHERE order_id = $2)`,
[totalValue, sell_order_id]
);

// 3. Update buyer's portfolio (add shares)
await client.query(
`INSERT INTO portfolios (user_id, symbol, quantity, average_price)
VALUES (
(SELECT user_id FROM orders WHERE order_id = $1),
$2, $3, $4
)
ON CONFLICT (user_id, symbol)
DO UPDATE SET quantity = portfolios.quantity + $3,
average_price = (portfolios.average_price * portfolios.quantity + $4 * $3) / (portfolios.quantity + $3)`,
[buy_order_id, symbol, quantity, price]
);

// 4. Update seller's portfolio (remove shares)
await client.query(
`UPDATE portfolios
SET quantity = quantity - $1
WHERE user_id = (SELECT user_id FROM orders WHERE order_id = $2)
AND symbol = $3
AND quantity >= $1`,
[quantity, sell_order_id, symbol]
);

// 5. Record the trade
await client.query(
`INSERT INTO trades (trade_id, buy_order_id, sell_order_id, symbol, quantity, price)
VALUES ($1, $2, $3, $4, $5, $6)`,
[this.generateTradeId(), buy_order_id, sell_order_id, symbol, quantity, price]
);

// 6. Update order statuses
await client.query(
`UPDATE orders SET status = 'FILLED', filled_quantity = quantity WHERE order_id = $1`,
[buy_order_id]
);
await client.query(
`UPDATE orders SET status = 'FILLED', filled_quantity = quantity WHERE order_id = $1`,
[sell_order_id]
);

await client.query('COMMIT');

// 7. Publish trade event for market data
await this.kafka.send('trades', { trade });

} catch (error) {
await client.query('ROLLBACK');
throw error; // The matching engine will retry or cancel
} finally {
client.release();
}
}
}

The database transaction is the safety net. If step 3 fails (seller doesn't have enough shares), the entire transaction rolls back. The buyer's money is returned. The trade never happened. This is why PostgreSQL, not Cassandra, handles balances and portfolios — you need ACID.

Market Data at Scale

50,000 trades/second need to be broadcast to millions of clients watching market data. This is a fan-out problem similar to WhatsApp group chat, but with stricter latency requirements.

// Market data broadcast
class MarketDataService {
constructor() {
// WebSocket connections grouped by subscription
this.subscriptions = new Map(); // symbol → Set<WebSocket>
}

// Called when a trade executes
async broadcastTrade(trade) {
const { symbol, price, quantity } = trade;

// 1. Update the in-memory ticker
this.tickers.set(symbol, {
last_price: price,
last_quantity: quantity,
timestamp: Date.now()
});

// 2. Broadcast to all subscribers of this symbol
const subscribers = this.subscriptions.get(symbol);
if (!subscribers) return;

const message = JSON.stringify({
type: 'trade',
symbol,
price,
quantity,
timestamp: Date.now()
});

// 3. Fan-out — send to all connected clients
// For 1M subscribers, this is 1M WebSocket sends
// Use a pub/sub system to distribute across market data servers
for (const ws of subscribers) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
}
}

For a real exchange with millions of subscribers, you'd use a pub/sub system (Redis Pub/Sub or Kafka) to fan out across multiple market data servers. Each server handles a subset of subscribers. The trade event is published once and delivered to all servers, which then deliver to their connected clients.

Bottlenecks and Trade-offs

Bottleneck 1: Single-threaded matching per symbol. If Reliance Industries gets 5,000 orders/second (possible during a results announcement), a single thread might not keep up. Solution: Shard by symbol. Reliance gets its own matching engine instance. TCS gets another. The 1,700 symbols are distributed across a pool of matching engines. Hot symbols get dedicated instances.

Bottleneck 2: Database transaction latency. Each trade settlement requires a database round-trip. At 50,000 trades/second peak, that's 50,000 transactions/second. PostgreSQL on a single instance can handle maybe 10,000 TPS. Solution: Batch settlements. Accumulate trades for 100ms and settle them in bulk. A single transaction can settle 100 trades. 50,000 trades/second becomes 500 transactions/second. Manageable.

Trade-off: Speed vs. durability. The matching engine operates entirely in memory for speed. If the server crashes, the in-memory order book is lost. Solution: Write-Ahead Log (WAL). Every order and trade is written to a persistent log (Kafka) before the matching engine processes it. On restart, replay the log to rebuild the order book. This adds ~1ms latency but guarantees no lost orders.

Trade-off: Market data consistency vs. latency. Broadcasting every trade to every subscriber with perfect ordering is expensive. Solution: Use sequence numbers. Each trade gets a monotonically increasing sequence number per symbol. Clients can detect gaps and request missing trades. This is eventual consistency with a recovery mechanism — acceptable for market data, not acceptable for order execution.

What the interviewer is testing: Do you understand that a trading system requires strong consistency, not eventual consistency? Can you explain why the matching engine is single-threaded? Do you know how to use database transactions for atomic settlement?


Your Turn: Practice System Design

Reading about system design is like reading about swimming. You cannot learn it from a book. You have to get in the water. Here are five exercises, one for each system we covered, that will take you from "I understand the concept" to "I can do this in an interview."

Exercise 1: URL Shortener — Build It

Do not just read the design. Build a working URL shortener this weekend. Use Node.js, PostgreSQL, and Redis. Implement:

  • POST /shorten — accepts a URL, returns a short code
  • GET /:code — redirects to the original URL
  • Rate limiting: 10 URLs per minute per IP
  • Analytics: track click count per URL

This should take 4-6 hours. The goal is not a production system. The goal is to feel the trade-offs in your fingers — what happens when two requests try to claim the same short code? How do you handle a redirect when Redis is down? Ship it. Break it. Fix it.

Exercise 2: WhatsApp — The Group Messaging Problem

The hardest part of WhatsApp is not one-on-one messaging. It is group messaging with 1,000 members. Design just the group message fan-out on paper:

  • A user sends a message to a group with 500 members
  • All 500 members should receive the message within 2 seconds
  • Some members are offline; they should receive the message when they come online
  • Draw the architecture. Estimate QPS for 100 million groups with an average of 50 members each.
  • Identify the bottleneck. (Hint: 500 writes per group message is expensive.)

Then implement a simplified version: a WebSocket server in Node.js that maintains group subscriptions and fans out messages. Use Redis Pub/Sub for cross-server communication. Test with 10 virtual clients.

Exercise 3: Netflix — Adaptive Bitrate in Practice

Take a 2-minute video file. Use ffmpeg to transcode it into three bitrates: 360p, 720p, 1080p. Segment each into 5-second chunks. Write a simple Node.js server that:

  • Serves a manifest listing the available bitrates and segments
  • Serves individual segments on demand
  • Logs which bitrate was requested for each segment

This teaches you more about video streaming than any diagram. You will understand why segment size matters (too small = too many requests; too large = slow start), why keyframes matter, and why CDN caching of segments is the foundation of Netflix's architecture.

Exercise 4: Uber — Geospatial Indexing with PostGIS

Install PostGIS (the geospatial extension for PostgreSQL). Create a table of 10,000 random "drivers" in Bangalore with latitude/longitude coordinates. Write a query that finds the 10 nearest drivers to a given point. Add an index. Measure the query time before and after the index.

Then implement the matching logic in Node.js:

  • Accept a rider's location
  • Find the 10 nearest available drivers
  • "Assign" the closest one (mark them unavailable)
  • After 30 seconds, if the driver hasn't accepted, try the next one

This is a simplified Uber matching engine. The real one has more complexity — driver preferences, traffic-aware ETAs, surge pricing — but the core geospatial query is exactly what you just built.

Exercise 5: Stock Exchange — Order Matching Engine

Build a single-symbol order matching engine in Node.js:

  • Maintain a buy order book (sorted by price descending, then time ascending)
  • Maintain a sell order book (sorted by price ascending, then time ascending)
  • When a new order arrives, match it against the opposite book
  • Partially filled orders stay in the book for the remaining quantity
  • Log every trade with price, quantity, buyer, seller, and timestamp

Test it: submit 10 buy orders and 10 sell orders at various prices. Verify that trades happen at the correct prices and quantities. Then add concurrency — fire 100 orders simultaneously and verify that the final state is correct. This is the core of what a stock exchange does, and building it will teach you more about concurrency than any textbook.

The Practice Rule

Do not do all five at once. Pick one this weekend. Build it. Break it. Fix it. Then pick another next weekend. After five weekends, you will have built five systems. You will have opinions about what works and what does not. You will have war stories about the bugs you encountered and the trade-offs you made.

That is what the interviewer is looking for. Not someone who read about system design. Someone who has built systems and learned from the scars.


The Revelation

You just designed five systems. URL Shortener. WhatsApp. Netflix. Uber. Stock Exchange.

Now look at the building blocks you used across all five:

  1. Load Balancers — Every system. Distributing traffic. Health checking.
  2. API Gateway — Every system. Auth, rate limiting, routing.
  3. Databases (SQL) — Every system. User data, metadata, balances.
  4. Databases (NoSQL) — WhatsApp (Cassandra), Uber (Cassandra), Stock Exchange (Cassandra for events). Append-only, time-series, high-write workloads.
  5. Cache (Redis) — URL Shortener, WhatsApp, Uber, Stock Exchange. Hot data, ephemeral state, pub/sub.
  6. Message Queue (Kafka) — WhatsApp, Uber, Stock Exchange. Decoupling services, durability, replay.
  7. CDN — URL Shortener, Netflix. Edge caching, reducing origin load.
  8. WebSocket — WhatsApp, Uber, Stock Exchange. Real-time bidirectional communication.
  9. Blob Storage (S3) — WhatsApp (media), Netflix (video segments). Large files, content-addressable.
  10. Distributed ID Generator — URL Shortener, WhatsApp, Uber, Stock Exchange. Unique IDs without coordination.

Ten building blocks. Five systems. Every single one.

The ₹80 LPA engineer doesn't know more systems than you. They know that system design is composition, not memorization. They walk into the interview with these ten blocks in their head, and when the interviewer says "Design Amazon," they don't panic. They ask: which blocks apply? Catalog search? That's read-heavy caching (URL Shortener) plus geospatial for delivery (Uber). Order placement? That's consistency (Stock Exchange) plus message queues (WhatsApp).

You now have the same toolkit. The same framework. The same ten blocks.

The next time an interviewer leans back and says "Design WhatsApp," you won't freeze. You'll pick up the marker, write "Requirements" on the board, and start asking questions. Because you know the secret: there are no new systems. Only new combinations of the same ten blocks.

And that's the difference between hoping you'll get lucky in the interview and knowing you'll nail it.