System Design Interview: How to Design Any System (Step-by-Step)
System design interviews are the most open-ended, highest-signal interviews in software engineering hiring. Unlike coding rounds, there is no single correct answer. The interviewer is evaluating how you think, how you communicate trade-offs, and whether you can navigate ambiguity the same way a senior engineer would in production.
This guide covers the complete framework, every major component you need to know, and 40 real interview questions with detailed answers and working code. Read it end to end before your next loop.
The Universal Framework: 6 Steps for Any System Design Question
Before diving into specific questions, internalize this framework. Apply it to every question, every time.
Step 1 — Clarify Requirements (5 minutes)
Never start drawing boxes. Start by asking questions. The interviewer will give you a vague prompt on purpose.
Axes to clarify:
- Functional requirements: What does the system do? What are the core user actions?
- Scale: How many users? Daily active users? Requests per second?
- Consistency vs. availability: Can users see stale data? What happens during a partition?
- Read/write ratio: Is this read-heavy (Twitter feed), write-heavy (logging), or balanced?
- Latency SLA: p99 < 100ms? Real-time? Batch is OK?
- Durability: What data can we afford to lose? (Usually: none)
- Geography: Single region? Multi-region? Global CDN needed?
Step 2 — Estimate Scale (5 minutes)
Back-of-envelope math shows the interviewer you understand capacity. It also drives every architectural decision.
Key numbers to know cold:
- 1 million DAU × 10 requests/day = ~115 RPS average, ~1,150 RPS peak (10× factor)
- 1 byte = 8 bits; 1 KB = 1,000 bytes; 1 GB = 10^9 bytes
- SSD random read: ~100 µs; network round-trip same DC: ~0.5 ms; cross-region: ~100 ms
- MySQL on decent hardware: ~5,000–10,000 writes/sec; ~50,000 reads/sec
- Redis: ~100,000–1,000,000 ops/sec
- Kafka: millions of messages/sec per broker
Step 3 — Define the API (5 minutes)
Write out the public-facing API before designing internals. This anchors the conversation.
POST /tweets { user_id, content, media_ids[] } → tweet_id
GET /feed/{user_id} ?limit=20&cursor=<token> → Tweet[]
GET /tweet/{id} → Tweet
DELETE /tweet/{id} → 204Step 4 — High-Level Design (10 minutes)
Draw the major components. At this stage: clients, load balancer, API servers, databases, caches, queues, CDN. No implementation details yet — just data flow.
Step 5 — Deep Dive (15 minutes)
The interviewer will steer you to the hard part. Common deep-dives: database schema, sharding strategy, cache invalidation, fan-out on write vs. read, consistency model, failure handling.
Step 6 — Identify Bottlenecks and Trade-offs (5 minutes)
Every design has weaknesses. Name them before the interviewer does. Discuss what you would change with more time or different constraints.
Core Building Blocks (Know These Cold)
Load Balancers
- L4 (TCP): faster, no HTTP awareness — use for non-HTTP or ultra-low latency
- L7 (HTTP): content-based routing, SSL termination, rate limiting — use almost everywhere
- Algorithms: Round Robin, Least Connections, IP Hash (sticky sessions), Consistent Hash
Databases
| Type | When to use | Examples |
|------|-------------|---------|
| Relational (RDBMS) | Transactions, complex joins, strong consistency | PostgreSQL, MySQL |
| Document | Flexible schema, nested objects | MongoDB, Firestore |
| Wide-column | Write-heavy, time-series, massive scale | Cassandra, HBase |
| Key-value | Cache, sessions, simple lookups | Redis, DynamoDB |
| Graph | Relationship traversals | Neo4j, Amazon Neptune |
| Search | Full-text, faceted search | Elasticsearch, Meilisearch |
Caching
- Cache-aside (lazy loading): App checks cache → miss → loads from DB → writes to cache. Simple, handles cold starts well.
- Write-through: Write to cache and DB simultaneously. Reads always hit cache. More consistent, more writes.
- Write-behind (write-back): Write to cache, async flush to DB. Fastest writes, risk of data loss.
- Read-through: Cache sits in front of DB, handles its own misses.
Cache eviction: LRU (most common), LFU, FIFO, TTL-based.
Message Queues
- Decouple producers from consumers
- Absorb traffic spikes (buffering)
- Enable async processing and retry logic
- Kafka: ordered, replayable, high throughput — use for event streaming, audit logs
- RabbitMQ/SQS: task queues, at-least-once delivery, simpler semantics
CDN
- Serves static assets (JS, CSS, images, video) from edge nodes near users
- Can also cache dynamic API responses with short TTLs
- Push CDN: you upload assets explicitly
- Pull CDN: first request goes to origin, subsequent requests hit cache
Consistent Hashing
Used to distribute load across nodes (caches, DB shards) with minimal redistribution when nodes are added/removed.
import hashlib
import bisect
class ConsistentHashRing:
def __init__(self, nodes=None, replicas=150):
self.replicas = replicas
self.ring = {}
self.sorted_keys = []
for node in (nodes or []):
self.add_node(node)
def add_node(self, node):
for i in range(self.replicas):
key = self._hash(f"{node}:{i}")
self.ring[key] = node
bisect.insort(self.sorted_keys, key)
def remove_node(self, node):
for i in range(self.replicas):
key = self._hash(f"{node}:{i}")
self.ring.pop(key, None)
idx = bisect.bisect_left(self.sorted_keys, key)
if idx < len(self.sorted_keys) and self.sorted_keys[idx] == key:
self.sorted_keys.pop(idx)
def get_node(self, key):
if not self.ring:
return None
h = self._hash(key)
idx = bisect.bisect(self.sorted_keys, h) % len(self.sorted_keys)
return self.ring[self.sorted_keys[idx]]
def _hash(self, key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)40 System Design Interview Questions — Detailed Answers
1. How do you approach a system design interview from scratch?
Use the 6-step framework: clarify requirements, estimate scale, define API, high-level design, deep dive, trade-offs. Never skip requirements clarification — many candidates dive into drawing boxes and solve the wrong problem. State your assumptions out loud. The interviewer wants to hear your reasoning, not just your conclusions.
Common mistake: Starting with implementation details (database choice, specific algorithms) before establishing what the system must do and at what scale.
2. Design a URL shortener (like bit.ly)
Requirements:
- Shorten a URL, return a short code (e.g.,
sht.ly/xK3m) - Redirect users from short URL to original
- 100M URLs shortened per day; 10B redirects per day
Scale estimation:
- Write: 100M/day = ~1,160 writes/sec
- Read: 10B/day = ~115,700 reads/sec (100:1 read/write ratio)
- Storage: 100M × 500 bytes average URL = 50 GB/day → ~18 TB/year
API:
POST /shorten { long_url, custom_alias?, ttl_days? } → { short_code }
GET /{code} → 301/302 redirect to long_urlShort code generation:
Option A — Random base62 (recommended):
import secrets
import string
ALPHABET = string.ascii_letters + string.digits # 62 chars
def generate_code(length=7) -> str:
# 62^7 = 3.5 trillion unique codes
return ''.join(secrets.choice(ALPHABET) for _ in range(length))Option B — Counter + base62 encode:
def encode_base62(num: int) -> str:
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
if num == 0:
return ALPHABET[0]
result = []
while num:
result.append(ALPHABET[num % 62])
num //= 62
return ''.join(reversed(result))Database schema:
CREATE TABLE urls (
short_code CHAR(7) PRIMARY KEY,
long_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
click_count BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX ON urls (long_url); -- for dedup checkArchitecture:
- Redis cache:
short_code → long_url(99%+ of reads served from cache, TTL matches URL expiry) - PostgreSQL for persistent storage
- Use 301 (permanent) redirect for SEO; use 302 (temporary) if you need click analytics (302 forces browser to re-request, letting you count clicks)
- For high write throughput, use a distributed counter (Snowflake ID) + base62 to avoid UUID collisions
Trade-offs:
- Random codes risk collisions at scale → check-on-insert with retry or use atomic counter
- Custom aliases need a separate uniqueness check
- Analytics (click counts) should be async (write to Kafka, aggregate separately) — don't block the redirect path
3. What is CAP theorem and how does it affect system design?
CAP theorem states a distributed system can guarantee at most two of three properties simultaneously:
- Consistency: Every read returns the most recent write or an error
- Availability: Every request receives a response (not necessarily up-to-date)
- Partition tolerance: System continues operating despite network partitions
Network partitions are unavoidable in distributed systems, so you always choose between CP (consistency + partition tolerance) or AP (availability + partition tolerance).
CP systems (choose consistency over availability):
- HBase, Zookeeper, etcd
- Use when: financial transactions, inventory management, anything where stale data causes real harm
AP systems (choose availability over consistency):
- Cassandra, CouchDB, DynamoDB (eventually consistent mode)
- Use when: social feeds, product catalogs, recommendations — users tolerate slightly stale data
In practice: Most systems allow you to tune the trade-off. Cassandra's quorum reads/writes let you slide between AP and CP:
# Strong consistency: R + W > N (e.g., N=3, W=2, R=2)
session.execute(
SimpleStatement(query, consistency_level=ConsistencyLevel.QUORUM)
)
# Eventual consistency: faster, less durable
session.execute(
SimpleStatement(query, consistency_level=ConsistencyLevel.ONE)
)PACELC is a more practical extension: even without a partition, there is a trade-off between latency and consistency (E: else, L: latency, C: consistency).
4. Design a key-value store (like Redis or DynamoDB)
Requirements: Get(key), Put(key, value), Delete(key). 1 billion keys, <10ms p99.
Core data structure — an in-memory hash map with a Write-Ahead Log (WAL) for durability:
import threading
import json
from pathlib import Path
class KVStore:
def __init__(self, wal_path="wal.log"):
self._store: dict = {}
self._lock = threading.RWLock() if hasattr(threading, 'RWLock') else threading.Lock()
self._wal = open(wal_path, "a")
self._replay_wal(wal_path)
def get(self, key: str):
with self._lock:
return self._store.get(key)
def put(self, key: str, value):
with self._lock:
self._append_wal("PUT", key, value)
self._store[key] = value
def delete(self, key: str):
with self._lock:
self._append_wal("DEL", key, None)
self._store.pop(key, None)
def _append_wal(self, op, key, value):
entry = json.dumps({"op": op, "key": key, "value": value}) + "\n"
self._wal.write(entry)
self._wal.flush() # fsync in production
def _replay_wal(self, path):
try:
for line in Path(path).read_text().splitlines():
entry = json.loads(line)
if entry["op"] == "PUT":
self._store[entry["key"]] = entry["value"]
elif entry["op"] == "DEL":
self._store.pop(entry["key"], None)
except FileNotFoundError:
passScaling to billions of keys:
- Shard by key hash across N nodes (consistent hashing)
- Replication: leader-follower (strong consistency) or leaderless (availability)
- Compaction: periodically merge WAL into a snapshot (LSM tree pattern)
LSM Tree (used by LevelDB, RocksDB, Cassandra):
- Writes go to in-memory memtable → flushed to immutable SSTables on disk
- Reads check memtable, then SSTables (newest to oldest)
- Background compaction merges and garbage-collects SSTables
B-Tree (used by PostgreSQL, MySQL, InnoDB):
- Reads are O(log n), random I/O
- Better for read-heavy workloads with complex queries
- Updates in-place (no compaction overhead)
5. Design Twitter's home feed (news feed)
Requirements: Users follow other users. Show posts from followed users, reverse-chronological, paginated.
Scale: 500M users, 200M DAU, ~500M tweets/day, home timeline reads ~10× writes.
The core problem — fan-out: when user A posts, their 1M followers all need to see it. Two approaches:
Fan-out on write (push model):
- On tweet creation, write to each follower's feed cache
- Read is O(1) — just read from pre-computed feed
- Write amplification: 1 tweet × 1M followers = 1M cache writes
- Works for users with normal follower counts (<10K)
Fan-out on read (pull model):
- On feed request, fetch recent tweets from each followed user, merge, sort
- No write amplification
- Expensive reads for users following many accounts
- Works for celebrity reads (you follow 1K people, merge 1K tweet lists)
Hybrid model (what Twitter actually uses):
- Regular users (<~10K followers): fan-out on write → pre-computed feed in Redis
- Celebrities (>~10K followers): fan-out on read, injected at read time
# Feed write path (simplified)
def handle_new_tweet(tweet: Tweet, follower_ids: list[int]):
# Store tweet
tweet_store.put(tweet.id, tweet)
# Fan out to regular followers
regular_followers = [
uid for uid in follower_ids
if follower_count[uid] < 10_000
]
for follower_id in regular_followers:
redis.lpush(f"feed:{follower_id}", tweet.id)
redis.ltrim(f"feed:{follower_id}", 0, 800) # keep last 800
# Feed read path
def get_feed(user_id: int, cursor: str, limit: int = 20) -> list[Tweet]:
# Get pre-computed feed from cache
cached_ids = redis.lrange(f"feed:{user_id}", 0, limit - 1)
# Inject tweets from celebrities the user follows
celebrity_ids = get_celebrity_followees(user_id)
celebrity_tweets = fetch_recent_tweets(celebrity_ids, since=cursor)
# Merge and sort
all_ids = merge_sorted([cached_ids, [t.id for t in celebrity_tweets]])
return [tweet_store.get(tid) for tid in all_ids[:limit]]Storage:
- Tweet content: distributed object store (Cassandra or DynamoDB)
- Feed lists: Redis sorted sets (score = timestamp)
- Follow graph: separate graph service or wide-column DB
6. How does consistent hashing work and when do you use it?
Standard modular hashing (hash(key) % N) breaks when you add or remove a node — nearly all keys get remapped to different nodes, causing a cache stampede.
Consistent hashing maps both keys and nodes onto a circular ring (0 to 2^32). A key is assigned to the first node clockwise from its position on the ring. Adding/removing a node only remaps keys in one arc segment (~1/N of total keys).
Virtual nodes (vnodes): Each physical node gets mapped to multiple positions on the ring (e.g., 150 virtual nodes per physical node). This ensures uniform distribution even when nodes have different capacities.
# From the ConsistentHashRing implementation above:
# Adding 1 node to a 3-node ring:
# Without vnodes: ~33% of keys remapped
# With 150 vnodes: still ~25% remapped (1/4 of ring), but distribution is uniform
ring = ConsistentHashRing(["cache-1", "cache-2", "cache-3"], replicas=150)
# Route request to correct cache node
node = ring.get_node("user:12345")
cache_client = cache_pool[node]
result = cache_client.get("user:12345")Use consistent hashing when: sharding a distributed cache, distributing load across database shards, partitioning a Kafka topic across consumers.
7. Design a rate limiter
Requirements: Limit requests per user/IP. Support different limits per endpoint. Distributed (multiple API servers share state).
Algorithms:
Token bucket (recommended for most cases):
- Each user has a bucket that fills at a fixed rate
- Each request consumes one token
- Allows bursts up to bucket capacity
import time
import redis
class TokenBucketRateLimiter:
def __init__(self, redis_client, capacity: int, refill_rate: float):
self.redis = redis_client
self.capacity = capacity
self.refill_rate = refill_rate # tokens per second
def allow(self, user_id: str) -> bool:
key = f"rate_limit:{user_id}"
now = time.time()
pipe = self.redis.pipeline()
pipe.hgetall(key)
results = pipe.execute()
data = results[0]
if data:
tokens = float(data[b"tokens"])
last_refill = float(data[b"last_refill"])
elapsed = now - last_refill
tokens = min(self.capacity, tokens + elapsed * self.refill_rate)
else:
tokens = self.capacity
last_refill = now
if tokens < 1:
return False
pipe = self.redis.pipeline()
pipe.hset(key, mapping={"tokens": tokens - 1, "last_refill": now})
pipe.expire(key, int(self.capacity / self.refill_rate) + 10)
pipe.execute()
return TrueSliding window log: Accurate but memory-intensive (stores timestamp of every request).
Sliding window counter: Approximate but memory-efficient. Uses two fixed windows (current + previous) weighted by overlap.
def sliding_window_allow(redis_client, user_id: str, limit: int, window_sec: int) -> bool:
now = int(time.time())
current_window = now // window_sec
prev_window = current_window - 1
current_key = f"rl:{user_id}:{current_window}"
prev_key = f"rl:{user_id}:{prev_window}"
pipe = redis_client.pipeline()
pipe.get(current_key)
pipe.get(prev_key)
current_count, prev_count = [int(x or 0) for x in pipe.execute()]
# Weight prev window by how much it overlaps with current sliding window
elapsed_in_window = now % window_sec
weighted_prev = prev_count * (1 - elapsed_in_window / window_sec)
estimated_count = weighted_prev + current_count
if estimated_count >= limit:
return False
pipe = redis_client.pipeline()
pipe.incr(current_key)
pipe.expire(current_key, window_sec * 2)
pipe.execute()
return TrueDistributed implementation: Use Redis with Lua scripts for atomic check-and-increment (avoids race conditions across multiple API servers).
8. Design a distributed cache
Requirements: Sub-millisecond reads, horizontal scaling, cache eviction, replication.
Partitioning: Consistent hashing across N cache nodes. Client-side routing (each app server knows the topology) vs. proxy-based routing (like Redis Cluster's gossip protocol).
Replication: Leader-follower per shard. Writes go to leader, replicated async to followers. Leader failure → follower election (Raft or Zookeeper-based).
Eviction policies:
LRU — evict least recently used (most common)
LFU — evict least frequently used (better for hot-key patterns)
TTL — expire by time (simplest, use for session data)
ARC — Adaptive Replacement Cache, tracks both recency and frequencyCache stampede / thundering herd — when a popular key expires and hundreds of requests simultaneously miss and all query the DB:
import threading
import time
# Solution 1: Probabilistic early expiration
def get_with_early_expiry(cache, db, key, ttl, beta=1.0):
cached = cache.get_with_ttl(key) # returns (value, remaining_ttl)
if cached:
value, remaining_ttl = cached
# Probabilistically recompute before expiry
if -beta * math.log(random.random()) > remaining_ttl:
value = db.get(key)
cache.set(key, value, ttl)
return value
value = db.get(key)
cache.set(key, value, ttl)
return value
# Solution 2: Mutex lock on cache miss
_locks = {}
_lock_meta = threading.Lock()
def get_with_mutex(cache, db, key, ttl):
value = cache.get(key)
if value:
return value
with _lock_meta:
if key not in _locks:
_locks[key] = threading.Lock()
lock = _locks[key]
with lock:
# Check again after acquiring lock
value = cache.get(key)
if value:
return value
value = db.get(key)
cache.set(key, value, ttl)
return valueHot key problem: One key gets millions of requests/sec. Solutions:
- 1Local in-process cache (L1 cache in front of Redis)
- 2Key replication:
hot_key → hot_key_shard_0...N, randomly pick shard on read - 3Read replicas dedicated to that shard
9. Design a web crawler
Requirements: Crawl 1 billion pages, store content, respect robots.txt, avoid duplicate crawls.
Components:
- 1URL Frontier (priority queue of URLs to crawl)
- 2Fetcher (HTTP client pool)
- 3Parser (extract links + content)
- 4Deduplication filter
- 5Storage (raw HTML + extracted content)
URL deduplication — use a Bloom filter for space-efficient membership check:
from bitarray import bitarray
import mmh3 # MurmurHash3
class BloomFilter:
def __init__(self, size: int, hash_count: int):
self.size = size
self.hash_count = hash_count
self.bit_array = bitarray(size)
self.bit_array.setall(0)
def add(self, item: str):
for seed in range(self.hash_count):
idx = mmh3.hash(item, seed) % self.size
self.bit_array[idx] = 1
def __contains__(self, item: str) -> bool:
return all(
self.bit_array[mmh3.hash(item, seed) % self.size]
for seed in range(self.hash_count)
)
# 1 billion URLs, ~1% false positive rate: ~10 bits/element = 1.25 GB
url_filter = BloomFilter(size=10_000_000_000, hash_count=7)Politeness: Each domain gets its own crawl queue with a minimum delay (e.g., 1 req/sec per domain). Use a priority heap sorted by next_crawl_time.
Distributed architecture:
- URL Frontier: Kafka topic partitioned by domain hash (keeps politeness per domain on one worker)
- Content store: HDFS or S3 for raw HTML
- Metadata DB: HBase or Cassandra (URL, crawl timestamp, content hash, HTTP status)
robots.txt: Cache per domain, respect Crawl-delay, honor Disallow rules.
10. Design a notification system
Requirements: Send push, email, and SMS notifications. 100M notifications/day. Delivery guarantees, deduplication, user preferences.
Flow:
Service → Notification Service → Queue → Workers → Third-party providers# Notification schema
{
"notification_id": "uuid",
"user_id": 12345,
"type": "push|email|sms",
"template_id": "order_shipped",
"template_vars": {"order_id": "ORD-999", "eta": "tomorrow"},
"priority": "high|normal|low",
"dedup_key": "order_shipped:ORD-999:user:12345"
}Deduplication: Before enqueuing, check dedup_key in Redis with a TTL matching your retry window (e.g., 24h). Idempotent inserts prevent duplicate sends from retries.
Multi-channel fan-out:
def dispatch_notification(notification: dict):
user_prefs = user_prefs_store.get(notification["user_id"])
channels = []
if user_prefs.push_enabled and notification["type"] in ("push", "all"):
channels.append(PushWorker)
if user_prefs.email_enabled and notification["type"] in ("email", "all"):
channels.append(EmailWorker)
if user_prefs.sms_enabled and notification["type"] in ("sms", "urgent"):
channels.append(SMSWorker)
for channel in channels:
queue.enqueue(channel.QUEUE_NAME, notification)Retry strategy: Exponential backoff with jitter. Failed notifications move to a dead-letter queue (DLQ) for inspection.
def retry_delay(attempt: int, base_ms=1000) -> float:
cap = 32_000 # max 32 seconds
delay = min(cap, base_ms * (2 ** attempt))
jitter = delay * 0.2 * random.random()
return (delay + jitter) / 1000 # in secondsAt-least-once delivery is standard. Ensure your third-party sends are idempotent (most providers accept an idempotency key).
11. How do you design a database schema for a social network?
Core entities: Users, Posts, Follows, Likes, Comments.
-- Users
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Posts
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
media_urls TEXT[],
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ON posts (user_id, created_at DESC);
-- Follow graph
CREATE TABLE follows (
follower_id BIGINT NOT NULL REFERENCES users(id),
followee_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX ON follows (followee_id); -- "who follows me" queries
-- Likes (high write volume — consider denormalized counter on posts)
CREATE TABLE likes (
post_id BIGINT NOT NULL REFERENCES posts(id),
user_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
-- Denormalized counter to avoid COUNT(*) on likes
ALTER TABLE posts ADD COLUMN like_count BIGINT DEFAULT 0;Scaling follow graph: At high scale, the follows table becomes a bottleneck for feed generation. Move to a graph database (Neo4j) or a specialized follow service backed by Cassandra.
Sharding: Shard posts and follows by user_id. Keep a user's data on the same shard for locality. Cross-shard queries (e.g., feed generation) require scatter-gather.
12. Explain database sharding strategies
Horizontal sharding splits rows across multiple database instances. Three main strategies:
Range-based sharding:
Shard 0: user_id 0–10M
Shard 1: user_id 10M–20M
...- Pro: Simple, range queries on shard key are fast
- Con: Hot spots (newest users are always on last shard)
Hash-based sharding:
def get_shard(user_id: int, num_shards: int) -> int:
return hash(user_id) % num_shards- Pro: Uniform distribution
- Con: Range queries require scatter-gather; resharding is painful (consistent hashing mitigates this)
Directory-based sharding:
- Lookup service maps key → shard
- Pro: Flexible, supports heterogeneous shard sizes
- Con: Lookup service is a bottleneck + SPOF unless highly available
Cross-shard challenges:
- JOINs: Must be done in application layer
- Transactions: Use two-phase commit (2PC) or saga pattern
- Unique IDs: Can't use auto-increment; use Snowflake IDs or UUIDs
Snowflake ID (Twitter's distributed ID generation):
41 bits timestamp | 10 bits machine ID | 12 bits sequenceimport time
class SnowflakeIDGenerator:
EPOCH = 1288834974657 # Twitter epoch (Nov 4, 2010)
MACHINE_ID_BITS = 10
SEQUENCE_BITS = 12
MAX_MACHINE_ID = (1 << MACHINE_ID_BITS) - 1
MAX_SEQUENCE = (1 << SEQUENCE_BITS) - 1
def __init__(self, machine_id: int):
assert 0 <= machine_id <= self.MAX_MACHINE_ID
self.machine_id = machine_id
self.sequence = 0
self.last_timestamp = -1
def generate(self) -> int:
ts = int(time.time() * 1000) - self.EPOCH
if ts == self.last_timestamp:
self.sequence = (self.sequence + 1) & self.MAX_SEQUENCE
if self.sequence == 0:
while ts <= self.last_timestamp:
ts = int(time.time() * 1000) - self.EPOCH
else:
self.sequence = 0
self.last_timestamp = ts
return (ts << 22) | (self.machine_id << 12) | self.sequence13. Design a distributed message queue (like Kafka)
Core concepts:
- Topic: Named stream of messages
- Partition: Ordered, immutable sequence of messages. Topics split into N partitions for parallelism.
- Offset: Position of a message within a partition
- Consumer group: Set of consumers dividing partitions among themselves
Guarantees:
- Messages within a partition are ordered
- At-least-once delivery by default (idempotent consumers needed)
- Exactly-once via transactions (more overhead)
Storage: Each partition is a segment of append-only log files on disk. Sequential writes are fast (~hundreds of MB/sec).
/data/kafka/
topic-orders/
partition-0/
00000000000000000000.log (segment 0)
00000000000000012345.log (segment 12345)
00000000000000012345.indexProducer with batching:
from confluent_kafka import Producer
producer = Producer({
"bootstrap.servers": "kafka-1:9092,kafka-2:9092",
"linger.ms": 5, # wait up to 5ms to batch messages
"batch.size": 65536, # 64KB batch
"acks": "all", # wait for all replicas
"enable.idempotence": True,
})
def send_order_event(order: dict):
producer.produce(
topic="orders",
key=str(order["user_id"]).encode(), # same user → same partition → ordered
value=json.dumps(order).encode(),
callback=delivery_report,
)
producer.poll(0) # trigger callbacks
def delivery_report(err, msg):
if err:
logger.error(f"Delivery failed: {err}")Consumer with manual offset commit:
from confluent_kafka import Consumer
consumer = Consumer({
"bootstrap.servers": "kafka-1:9092",
"group.id": "order-processor",
"auto.offset.reset": "earliest",
"enable.auto.commit": False, # manual commit for exactly-once processing
})
consumer.subscribe(["orders"])
while True:
msg = consumer.poll(timeout=1.0)
if msg is None or msg.error():
continue
order = json.loads(msg.value())
process_order(order) # idempotent
consumer.commit(message=msg) # commit only after successful processingReplication: Each partition has a leader and N-1 followers. Producers write to leader, followers replicate. If leader dies, a follower with all committed messages becomes the new leader (ISR — In-Sync Replicas).
14. Design a search autocomplete system
Requirements: As user types, return top 10 completions ranked by search frequency. Latency < 100ms.
Trie-based approach (in-memory, single machine):
class TrieNode:
def __init__(self):
self.children: dict[str, 'TrieNode'] = {}
self.is_end = False
self.frequency = 0
self.top_suggestions: list[tuple[int, str]] = [] # (freq, word), max-heap
class AutocompleteTrie:
def __init__(self, max_suggestions=10):
self.root = TrieNode()
self.max_suggestions = max_suggestions
def insert(self, word: str, frequency: int):
node = self.root
for ch in word.lower():
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
node.frequency = frequency
self._update_suggestions(word, frequency)
def _update_suggestions(self, word: str, freq: int):
node = self.root
for ch in word.lower():
node = node.children[ch]
suggestions = node.top_suggestions
# Keep top-K sorted by frequency (use heap for efficiency)
if len(suggestions) < self.max_suggestions:
suggestions.append((freq, word))
suggestions.sort(reverse=True)
elif freq > suggestions[-1][0]:
suggestions[-1] = (freq, word)
suggestions.sort(reverse=True)
def search(self, prefix: str) -> list[str]:
node = self.root
for ch in prefix.lower():
if ch not in node.children:
return []
node = node.children[ch]
return [word for _, word in node.top_suggestions]At scale:
- Store trie in shared memory (memcached) or serialize to Redis
- Precompute top-K suggestions per prefix node offline (batch job every hour from search logs)
- Partition trie by first 2 characters:
prefix[:2]→ shard ID
Real-time frequency updates: Log searches to Kafka → Flink job aggregates counts → updates trie every hour (full rebuild) or 10 minutes (incremental diff).
Typo tolerance: Use Elasticsearch with edge n-gram tokenizer for fuzzy matching at the cost of higher latency (still < 100ms for most queries).
15. Design a payment system
Requirements: Process payments, handle failures, prevent double charges, support refunds.
The core challenge: Payments require exactly-once semantics. Network failures make this hard — you don't know if the charge succeeded or failed.
Idempotency keys:
def charge_card(
user_id: int,
amount_cents: int,
currency: str,
idempotency_key: str, # client-generated UUID, stored by caller
) -> PaymentResult:
# Check if we already processed this request
existing = db.get_payment_by_idempotency_key(idempotency_key)
if existing:
return existing # return same result, no double charge
# Create payment record in PENDING state FIRST
payment_id = db.create_payment(
user_id=user_id,
amount=amount_cents,
currency=currency,
idempotency_key=idempotency_key,
status="PENDING",
)
try:
# Call payment provider (Stripe, Adyen, etc.)
provider_result = stripe.charge(
amount=amount_cents,
currency=currency,
customer_id=user_id,
idempotency_key=idempotency_key,
)
db.update_payment(payment_id, status="SUCCESS", provider_id=provider_result.id)
return PaymentResult(success=True, payment_id=payment_id)
except stripe.CardError as e:
db.update_payment(payment_id, status="FAILED", error=str(e))
return PaymentResult(success=False, error=str(e))
except Exception as e:
# Network timeout — status is UNKNOWN, not FAILED
db.update_payment(payment_id, status="UNKNOWN", error=str(e))
# Reconciliation job will resolve this async
raisePayment state machine:
PENDING → SUCCESS → REFUNDED
→ FAILED
→ UNKNOWN → (reconciliation) → SUCCESS | FAILEDDouble-spend prevention: Database unique constraint on idempotency_key. Any duplicate request returns the stored result.
Reconciliation: A background job periodically queries the payment provider for all UNKNOWN/PENDING payments and updates status. Essential for handling network timeouts.
Ledger pattern: Never update balances in place. Append-only ledger entries:
CREATE TABLE ledger (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
amount_cents BIGINT NOT NULL, -- positive = credit, negative = debit
type VARCHAR(50) NOT NULL, -- 'charge', 'refund', 'payout'
reference_id VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Balance = SUM(amount_cents) WHERE account_id = X16. Design a video streaming service (like YouTube)
Upload pipeline:
User → Upload Service → Raw Storage (S3)
→ Transcoding Job Queue (Kafka)
→ Transcoding Workers (FFmpeg)
→ Multiple resolution outputs (S3: 360p, 720p, 1080p, 4K)
→ CDN edge nodesTranscoding at scale:
- Split video into 5-second GOP (Group of Pictures) chunks
- Transcode each chunk in parallel across a worker pool
- Reassemble segments into final output
- Each resolution is a separate job: 360p/720p/1080p/4K
- 1 hour of raw video → 2-4 hours of CPU compute → cloud-scale worker fleet reduces to minutes
Adaptive Bitrate Streaming (HLS/DASH):
- Player requests a manifest file (
.m3u8) listing available bitrates - Fetches 2–10 second segments from CDN
- Switches bitrate based on measured bandwidth
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
360p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720
720p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080
1080p/playlist.m3u8CDN strategy: Cache segments at edge. Video-on-demand → high cache hit rate (popular videos watched millions of times). Live streaming → segments are never cached, must be served from origin or very short TTL.
Metadata storage:
CREATE TABLE videos (
id VARCHAR(11) PRIMARY KEY, -- YouTube-style ID
user_id BIGINT NOT NULL,
title TEXT NOT NULL,
description TEXT,
duration_sec INT,
status VARCHAR(20) NOT NULL, -- 'processing', 'ready', 'failed'
view_count BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);View counts are eventually consistent — increment in Redis, flush to DB in batches.
17. Design a ride-sharing service (like Uber)
Core challenge: Match riders to nearby drivers in real time. Geospatial queries at scale.
Geohashing: Encode lat/lng as a short string. Adjacent cells share prefixes.
import geohash # python-geohash library
# Driver location update
def update_driver_location(driver_id: int, lat: float, lng: float):
gh = geohash.encode(lat, lng, precision=6) # ~1km accuracy
# Store in Redis geospatial index
redis.geoadd("active_drivers", lng, lat, driver_id)
# Also store in geohash bucket for fast proximity queries
redis.sadd(f"drivers:geohash:{gh}", driver_id)
redis.expire(f"drivers:geohash:{gh}", 30) # drivers expire if not updated
# Find nearby drivers
def find_nearby_drivers(lat: float, lng: float, radius_km: float = 5) -> list[int]:
results = redis.georadius(
"active_drivers", lng, lat,
radius_km, unit="km",
withdist=True, count=20, sort="ASC"
)
return [(int(driver_id), dist) for driver_id, dist in results]Matching algorithm:
- Find N nearest available drivers
- Estimated time of arrival (ETA) for each
- Assign to driver with shortest ETA
- Reject if ETA > threshold (e.g., 10 min)
Trip state machine:
REQUESTED → ACCEPTED → DRIVER_ARRIVING → IN_PROGRESS → COMPLETED
→ CANCELLED (by rider or driver)
→ NO_DRIVER_FOUNDSurge pricing: Calculate supply/demand ratio per geohash cell. If demand / supply > threshold, apply multiplier. Pre-compute cell-level stats every 5 minutes.
18. Design a distributed lock service
Requirements: Mutual exclusion across distributed services. Fault-tolerant. Avoid deadlocks.
Redis-based distributed lock (Redlock):
import uuid
import time
import redis
class DistributedLock:
def __init__(self, redis_client, key: str, ttl_ms: int = 10000):
self.redis = redis_client
self.key = f"lock:{key}"
self.ttl_ms = ttl_ms
self.token = str(uuid.uuid4())
def acquire(self, retry_times=3, retry_delay_ms=200) -> bool:
for _ in range(retry_times):
# SET key token NX PX ttl — atomic, only sets if not exists
result = self.redis.set(
self.key, self.token,
nx=True, px=self.ttl_ms
)
if result:
return True
time.sleep(retry_delay_ms / 1000)
return False
def release(self):
# Lua script ensures we only release our own lock (atomic check-and-delete)
script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
self.redis.eval(script, 1, self.key, self.token)
def __enter__(self):
if not self.acquire():
raise RuntimeError(f"Could not acquire lock: {self.key}")
return self
def __exit__(self, *args):
self.release()
# Usage
with DistributedLock(redis_client, "order:12345", ttl_ms=5000):
process_order(12345)Redlock (multi-node, more fault-tolerant): Acquire lock on N/2+1 Redis nodes. If majority acquired within validity_time = ttl - elapsed - drift, lock is valid. On failure or partial success, release all nodes.
When Redis locks aren't enough: Use Zookeeper ephemeral nodes (lock released automatically on client disconnect) or etcd leases for stronger guarantees.
19. How do you design for high availability (HA)?
HA = eliminating single points of failure (SPOF)
Checklist:
- Load balancer: at least 2 (active-active or active-passive with VIP failover)
- App servers: N+1 minimum, auto-scaling group
- Database: primary + at least one replica; automated failover (e.g., RDS Multi-AZ, Patroni for PostgreSQL)
- Cache: Redis Sentinel or Redis Cluster
- Message queue: Kafka with replication factor 3, min.insync.replicas=2
Multi-region:
- Active-active: both regions serve traffic, data replicated across regions; risk of split-brain
- Active-passive: one region serves traffic, second is hot standby; simpler but higher failover time
Circuit breaker pattern — stop calling a failing service:
from enum import Enum
import time
class CircuitState(Enum):
CLOSED = "closed" # normal operation
OPEN = "open" # failing, reject all calls
HALF_OPEN = "half_open" # test if service recovered
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_timeout=60):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failure_count = 0
self.last_failure_time = None
self.state = CircuitState.CLOSED
def call(self, func, *args, **kwargs):
if self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
else:
raise Exception("Circuit breaker OPEN — service unavailable")
try:
result = func(*args, **kwargs)
if self.state == CircuitState.HALF_OPEN:
self.reset()
return result
except Exception:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
raise
def reset(self):
self.failure_count = 0
self.state = CircuitState.CLOSEDHealth checks: Liveness (is the process alive?) vs. readiness (is it ready to serve traffic?). Kubernetes distinguishes these. A pod can be alive but not ready (e.g., still warming up cache).
20. Design a file storage system (like Google Drive or Dropbox)
Requirements: Upload/download files, sync across devices, share with other users, version history.
Chunked upload:
- Split files into 4MB chunks
- Upload chunks in parallel
- Resume interrupted uploads from the last successful chunk
- Deduplication: hash each chunk (SHA-256); if chunk already stored, skip upload
import hashlib
def compute_chunk_hash(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def upload_file(file_path: str, user_id: int, chunk_size: int = 4 * 1024 * 1024):
chunks = []
with open(file_path, "rb") as f:
while True:
data = f.read(chunk_size)
if not data:
break
chunk_hash = compute_chunk_hash(data)
chunks.append(chunk_hash)
# Only upload if not already stored (content-addressable)
if not chunk_store.exists(chunk_hash):
chunk_store.put(chunk_hash, data)
# Store file metadata: list of chunk hashes in order
file_id = metadata_store.create_file(
user_id=user_id,
filename=os.path.basename(file_path),
chunk_hashes=chunks,
size=os.path.getsize(file_path),
)
return file_idStorage: Chunks go to S3 (or equivalent object store) keyed by their hash. This is content-addressable storage — same content stored once regardless of how many users have the same file.
Sync protocol:
- 1Client computes local file tree with hashes
- 2Delta sync: compare with server state, only transfer changed chunks
- 3Server sends change notifications via WebSocket or long-polling
- 4Conflict resolution: last-write-wins, or create a "conflicted copy" (Dropbox's approach)
Metadata DB:
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT NOT NULL,
parent_id UUID REFERENCES files(id), -- folder hierarchy
name VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL,
is_folder BOOLEAN NOT NULL DEFAULT FALSE,
version INT NOT NULL DEFAULT 1,
chunk_hashes TEXT[], -- ordered list of content-addressable chunk hashes
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);21. Design a recommendation system
Two main approaches:
Collaborative filtering: "Users like you also liked..."
- Item-based: find items similar to what user liked (cosine similarity on item vectors)
- User-based: find similar users, recommend what they liked
- Matrix factorization (SVD, ALS): decompose user-item matrix into latent factor vectors
Content-based filtering: "Because you liked X (which has these attributes)..."
- Build item feature vectors (genre, tags, metadata)
- Build user preference vectors from their history
- Recommend items with high similarity to user vector
In practice (Netflix/Spotify approach): ensemble both + deep learning.
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
class ItemBasedCF:
def __init__(self):
self.item_vectors = {} # item_id → embedding vector
self.similarity_matrix = None
def train(self, interaction_matrix: np.ndarray, item_ids: list[int]):
# interaction_matrix: users × items, values = implicit feedback (views, clicks)
# Transpose: items × users
item_user_matrix = interaction_matrix.T
self.similarity_matrix = cosine_similarity(item_user_matrix)
self.item_ids = item_ids
def recommend(self, user_history: list[int], n: int = 10) -> list[int]:
if not user_history:
return self._popular_items(n)
# Average similarity across all items user interacted with
history_indices = [self.item_ids.index(i) for i in user_history if i in self.item_ids]
scores = self.similarity_matrix[history_indices].mean(axis=0)
# Exclude already-seen items
for idx in history_indices:
scores[idx] = -1
top_indices = np.argsort(scores)[-n:][::-1]
return [self.item_ids[i] for i in top_indices]Two-tower neural network (Google's approach for YouTube):
- User tower: embeddings from user history, demographics
- Item tower: embeddings from item features
- Train to maximize dot product for positive (user, item) pairs
- Serve: pre-compute all item embeddings → approximate nearest neighbor search (FAISS) at query time
22. What is the difference between SQL and NoSQL databases?
| Property | SQL (Relational) | NoSQL |
|---|---|---|
| Schema | Fixed, enforced | Flexible (document) or schema-free |
| Transactions | ACID by default | Often BASE (eventual consistency) |
| Joins | Native, efficient | Application-level or denormalized |
| Scaling | Vertical + read replicas | Horizontal sharding (native) |
| Query flexibility | Arbitrary SQL | Limited to access patterns defined at design time |
| Consistency | Strong by default | Configurable (eventual to strong) |
When to use SQL: Financial data, user accounts, anything requiring transactions or complex queries. PostgreSQL handles surprisingly high scale with proper indexing and connection pooling.
When to use NoSQL:
- Document (MongoDB): Content management, user profiles, product catalogs with variable attributes
- Wide-column (Cassandra): Write-heavy time-series, IoT data, chat messages, event logs
- Key-value (Redis): Session store, cache, leaderboards, pub/sub
- Graph (Neo4j): Fraud detection, social networks, recommendation graphs
The ACID properties:
- Atomicity: All-or-nothing transactions
- Consistency: DB transitions between valid states only
- Isolation: Concurrent transactions don't see each other's partial state
- Durability: Committed transactions survive crashes (WAL)
23. Design a leaderboard system
Requirements: Real-time rankings, top-K queries, user rank lookup, score updates.
Redis Sorted Sets — the perfect data structure for this:
import redis
class Leaderboard:
def __init__(self, redis_client, name: str):
self.redis = redis_client
self.key = f"leaderboard:{name}"
def add_score(self, user_id: int, score: float):
self.redis.zadd(self.key, {str(user_id): score})
def increment_score(self, user_id: int, delta: float) -> float:
return self.redis.zincrby(self.key, delta, str(user_id))
def get_rank(self, user_id: int) -> int:
# ZREVRANK: rank from highest (rank 0 = #1)
rank = self.redis.zrevrank(self.key, str(user_id))
return rank + 1 if rank is not None else None
def get_top_k(self, k: int = 10) -> list[dict]:
results = self.redis.zrevrange(self.key, 0, k - 1, withscores=True)
return [
{"rank": i + 1, "user_id": int(uid), "score": score}
for i, (uid, score) in enumerate(results)
]
def get_user_context(self, user_id: int, window: int = 5) -> list[dict]:
# Return user's neighbors on the leaderboard
rank = self.redis.zrevrank(self.key, str(user_id))
if rank is None:
return []
start = max(0, rank - window)
end = rank + window
results = self.redis.zrevrange(self.key, start, end, withscores=True)
return [
{"rank": start + i + 1, "user_id": int(uid), "score": score}
for i, (uid, score) in enumerate(results)
]
# O(log N) for add/update/rank-lookup
# O(log N + K) for top-K
lb = Leaderboard(redis_client, "weekly_game_2024_01")
lb.add_score(user_id=42, score=9850)
lb.get_top_k(10)Persistence: Redis sorted sets are in-memory. Persist to PostgreSQL daily snapshots. For multi-region: replicate Redis or use a CRDT-based counter service.
Sharding large leaderboards: If a single leaderboard exceeds Redis memory (e.g., 100M players), partition into shards of 1M users each. Merge top-K from each shard to compute global top-K.
24. Design a type-ahead search (Google-style)
Already covered in Q14. Supplemental: how does Google scale this to billions of queries?
Multi-level architecture:
- 1Client-side: 300ms debounce, local LRU cache of recent prefix→results
- 2Edge cache (CDN): cache popular prefixes (top 1M prefixes cover ~80% of traffic)
- 3Regional cache (Redis): prefix → top-10 results, 5-minute TTL
- 4Backend trie service: falls through to this only on cold prefixes
Result personalization: Blend global popularity scores with user's search history. At query time:
final_score = 0.7 × global_freq + 0.3 × personal_freqSpell correction: For unrecognized prefixes, run edit-distance-1 candidates through the trie. Limit candidates with BK-tree pruning.
25. Explain the saga pattern for distributed transactions
When a transaction spans multiple services, you can't use a single database ACID transaction. The saga pattern breaks it into a sequence of local transactions, each with a compensating transaction for rollback.
Choreography-based saga (event-driven):
OrderService → publishes ORDER_CREATED
InventoryService → reserves stock → publishes STOCK_RESERVED
PaymentService → charges card → publishes PAYMENT_PROCESSED
ShipmentService → creates shipment → publishes ORDER_FULFILLED
On failure at any step → publish failure event → compensating transactions run in reverse
ShipmentService fails → publishes SHIPMENT_FAILED
PaymentService (compensate) → refunds charge
InventoryService (compensate) → releases reservationOrchestration-based saga (central coordinator):
class OrderSaga:
def __init__(self, order_id: int):
self.order_id = order_id
self.steps = [
Step(
action=lambda: inventory_service.reserve(order_id),
compensation=lambda: inventory_service.release(order_id),
),
Step(
action=lambda: payment_service.charge(order_id),
compensation=lambda: payment_service.refund(order_id),
),
Step(
action=lambda: shipment_service.create(order_id),
compensation=lambda: shipment_service.cancel(order_id),
),
]
def execute(self):
completed = []
for step in self.steps:
try:
step.action()
completed.append(step)
except Exception as e:
# Rollback in reverse order
for done_step in reversed(completed):
done_step.compensation()
raise SagaRollbackError(f"Saga failed at step: {e}")Trade-offs:
- Sagas are ACD (not I — no isolation). Dirty reads between steps are possible.
- Compensating transactions can't always perfectly undo (e.g., email sent → can't unsend)
- Choreography is simpler but harder to trace; orchestration is observable but adds coupling
26. Design a real-time chat system (like Slack)
Requirements: 1:1 and group messages, real-time delivery, message history, read receipts, typing indicators.
Real-time delivery: WebSocket connection from each client to a gateway server.
# FastAPI WebSocket gateway (simplified)
from fastapi import WebSocket
import asyncio
connected_users: dict[int, WebSocket] = {}
async def websocket_endpoint(websocket: WebSocket, user_id: int):
await websocket.accept()
connected_users[user_id] = websocket
try:
while True:
data = await websocket.receive_json()
if data["type"] == "message":
await handle_message(user_id, data)
elif data["type"] == "typing":
await broadcast_typing(user_id, data["channel_id"])
finally:
del connected_users[user_id]
async def handle_message(sender_id: int, data: dict):
msg = await message_store.save({
"channel_id": data["channel_id"],
"sender_id": sender_id,
"content": data["content"],
"created_at": datetime.utcnow().isoformat(),
})
# Deliver to all online members of the channel
members = await channel_service.get_members(data["channel_id"])
for member_id in members:
if member_id in connected_users:
await connected_users[member_id].send_json({
"type": "message",
"message": msg,
})
else:
# User is offline — send push notification
await push_service.notify(member_id, msg)Scaling WebSockets: Each gateway server handles ~10K–100K connections. With 10M concurrent users, that's 100–1,000 servers. Route user connections consistently (same user, same server) using consistent hashing on user_id at the load balancer.
Cross-server delivery: When user A (on server 1) sends a message to user B (on server 2), server 1 publishes to Redis pub/sub. Server 2 subscribes and delivers to B's WebSocket.
Message storage: Cassandra is ideal. Partition key: channel_id, clustering key: message_id (Snowflake, naturally ordered by time). Supports fast retrieval of most recent messages per channel.
-- Cassandra
CREATE TABLE messages (
channel_id UUID,
message_id BIGINT, -- Snowflake ID (time-ordered)
sender_id UUID,
content TEXT,
PRIMARY KEY (channel_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);Offline message delivery: On reconnect, client sends last seen message_id. Server returns all messages with message_id > last_seen for each channel.
27. Design an API gateway
Responsibilities: Authentication, rate limiting, routing, SSL termination, request/response transformation, logging, circuit breaking.
class APIGateway:
def __init__(self):
self.auth = JWTAuthMiddleware()
self.rate_limiter = TokenBucketRateLimiter(redis_client, capacity=100, refill_rate=10)
self.router = ServiceRouter()
self.circuit_breakers = {} # service_name → CircuitBreaker
async def handle(self, request: Request) -> Response:
# 1. Auth
try:
user = await self.auth.authenticate(request)
except AuthError:
return Response(status=401, body="Unauthorized")
# 2. Rate limiting
if not self.rate_limiter.allow(user.id):
return Response(status=429, body="Too Many Requests",
headers={"Retry-After": "1"})
# 3. Route to upstream service
service, path = self.router.resolve(request.path)
# 4. Circuit breaker
cb = self.circuit_breakers.setdefault(service, CircuitBreaker())
try:
upstream_response = await cb.call(
http_client.forward, request, service, path
)
except Exception:
return Response(status=503, body="Service temporarily unavailable")
# 5. Response transformation (e.g., strip internal headers)
return self.transform_response(upstream_response)Service discovery: Gateway queries service registry (Consul, etcd, or Kubernetes endpoints) to find upstream service instances. Health check endpoints drive traffic away from unhealthy instances automatically.
28. Design a monitoring and alerting system
Components:
- Metrics collection: Agents on each host (Prometheus exporters, StatsD) expose
/metricsendpoints - Time series DB: Prometheus, InfluxDB, or Victoria Metrics
- Query engine: PromQL for Prometheus
- Alerting: AlertManager evaluates alert rules, routes to PagerDuty/Slack
- Dashboards: Grafana
Key metric types:
- Counter: monotonically increasing (requests_total, errors_total)
- Gauge: current value (memory_usage_bytes, active_connections)
- Histogram: distribution of values (request_duration_seconds)
- Summary: similar to histogram but quantiles computed client-side
# Prometheus client instrumentation
from prometheus_client import Counter, Histogram, Gauge, start_http_server
import time
requests_total = Counter("http_requests_total", "Total HTTP requests", ["method", "endpoint", "status"])
request_duration = Histogram("http_request_duration_seconds", "Request duration", ["endpoint"])
active_connections = Gauge("active_connections", "Active WebSocket connections")
def track_request(func):
def wrapper(request):
start = time.time()
try:
response = func(request)
requests_total.labels(
method=request.method,
endpoint=request.path,
status=response.status_code
).inc()
return response
finally:
request_duration.labels(endpoint=request.path).observe(time.time() - start)
return wrapperSLO/SLA design:
- SLI (indicator): actual measured metric (e.g., p99 latency = 120ms)
- SLO (objective): target (e.g., p99 latency < 200ms, 99.9% of requests)
- Error budget: 100% - SLO. If 99.9% SLO, you have 0.1% budget = 43.8 min/month downtime allowed
- Burn rate alerts: alert when error budget is being consumed too fast
29. How do you handle database migrations in production?
Golden rule: Migrations must be backwards-compatible. The old version of the app must work with the new schema, and vice versa. This enables zero-downtime deploys.
Expand-Contract pattern (for zero-downtime column rename):
-- Step 1: Expand — add new column (both versions work)
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
-- Step 2: Migrate data (run as a background job)
UPDATE users SET full_name = first_name || ' ' || last_name WHERE full_name IS NULL;
-- Step 3: Deploy new code that reads full_name (old code still reads first_name)
-- Step 4: Contract — drop old columns (only after old code is fully retired)
ALTER TABLE users DROP COLUMN first_name;
ALTER TABLE users DROP COLUMN last_name;For adding an index without locking:
-- Standard: locks the table
CREATE INDEX ON users (email);
-- PostgreSQL: build index without locking (takes longer but safe in prod)
CREATE INDEX CONCURRENTLY ON users (email);For large table migrations: Never run ALTER TABLE on a table with 500M rows during business hours. Use a shadow table approach:
- 1Create new table with new schema
- 2Copy rows in batches of 10K (with sleep between batches)
- 3Trigger/changelog to capture changes during migration
- 4Atomic rename:
RENAME TABLE users TO users_old, users_new TO users
Tools: gh-ost (GitHub) for MySQL, pg_repack for PostgreSQL.
30. Design an e-commerce inventory system
The core problem: Prevent overselling (selling more units than you have in stock) under high concurrent load (flash sales, Black Friday).
Naive approach fails:
# WRONG: race condition
def purchase(product_id, quantity):
stock = db.query("SELECT stock FROM inventory WHERE id = ?", product_id)
if stock >= quantity: # another request reads stock=5 here too
db.execute("UPDATE inventory SET stock = stock - ? WHERE id = ?", quantity, product_id)
# Both succeed → stock goes negativeCorrect approach — optimistic locking:
UPDATE inventory
SET stock = stock - :quantity, version = version + 1
WHERE product_id = :product_id
AND stock >= :quantity
AND version = :expected_version;
-- Returns 0 rows updated → retry with fresh dataOr: Redis atomic decrement with floor:
def reserve_stock(product_id: int, quantity: int) -> bool:
key = f"stock:{product_id}"
# Lua script: atomic check-and-decrement
script = """
local stock = tonumber(redis.call("get", KEYS[1]))
if stock == nil or stock < tonumber(ARGV[1]) then
return 0
end
redis.call("decrby", KEYS[1], ARGV[1])
return 1
"""
result = redis.eval(script, 1, key, quantity)
if result:
# Async: persist reservation to DB
queue.enqueue("persist_reservation", product_id, quantity)
return bool(result)Flash sale architecture:
- Pre-load stock counts into Redis before sale starts
- All stock checks go through Redis (not DB)
- DB updated asynchronously via queue
- Use Lua scripts for atomic operations (no TOCTOU race)
- Queue overflow: return "queue position" token if stock exhausted momentarily — let user wait
31. How do you design a CDN?
Components:
- Edge nodes (Points of Presence, PoPs) distributed globally
- Origin servers (your actual infrastructure)
- DNS-based traffic routing (Anycast or GeoDNS → nearest edge)
Caching hierarchy:
Browser cache (TTL: seconds–minutes)
→ CDN edge cache (TTL: minutes–hours)
→ CDN origin shield (aggregates misses, reduces origin load)
→ Your origin serversCache-Control headers:
Cache-Control: public, max-age=86400 # cache for 1 day
Cache-Control: public, max-age=31536000, immutable # forever (hashed filenames)
Cache-Control: no-store # never cache (sensitive data)
Vary: Accept-Encoding # separate cache for gzip vs brotliCache invalidation: The hardest problem. Two strategies:
- 1Content-addressed URLs:
bundle.a3f9c1.js— hash in filename means new content = new URL = no invalidation needed (set immutable, max-age forever) - 2Purge API: explicitly invalidate specific URLs or tag-based groups (
cdn.purge(tag="product:123"))
For API responses (dynamic CDN):
- Short TTL (5–60 seconds) handles most traffic for popular content
Surrogate-Key/Cache-Tagheaders enable targeted purgesstale-while-revalidateserves stale content while fetching fresh in background
32. Explain eventual consistency with a concrete example
Eventual consistency means: if no new updates are made to a piece of data, eventually all reads will return the same (latest) value. There is no guarantee of when this happens.
Concrete example: shopping cart in a distributed system
User adds item on mobile (writes to US-West datacenter). User checks cart on laptop 200ms later, request routes to US-East datacenter. Replication lag = 150ms. Cart appears empty for 150ms, then the item appears. This is eventual consistency.
Conflict resolution strategies:
Last-write-wins (LWW): timestamp wins. Simple but can lose data if clocks are skewed.
CRDTs (Conflict-free Replicated Data Types): data structures that merge without conflict:
# G-Counter (grow-only counter): each node increments its own slot
class GCounter:
def __init__(self, node_id: str, nodes: list[str]):
self.node_id = node_id
self.counts = {n: 0 for n in nodes}
def increment(self, delta=1):
self.counts[self.node_id] += delta
def value(self) -> int:
return sum(self.counts.values())
def merge(self, other: 'GCounter'):
# Take element-wise maximum — always safe, no conflicts
for node, count in other.counts.items():
self.counts[node] = max(self.counts[node], count)Shopping cart CRDT: add-wins set (OR-Set) — add and remove are both tagged with unique IDs; concurrent add+remove resolves to "added".
33. Design an ad serving system
Requirements: Serve relevant ads to users. < 50ms latency. Billions of ad auctions per day.
Flow:
User visits page → Publisher sends bid request (user signals, page context)
→ Ad Exchange (real-time auction)
→ DSPs (Demand-Side Platforms) respond with bids in < 100ms
→ Highest bidder wins, ad served
→ Impression/click events loggedTargeting: Segment users by demographics, interests, behavioral signals (pages visited, purchases). Store in fast key-value store (Redis, Aerospike).
def run_auction(bid_request: BidRequest) -> AdCreative:
# 1. Retrieve eligible ads for this user+context (< 5ms)
user_segments = segment_store.get(bid_request.user_id)
eligible_ads = ad_index.query(
segments=user_segments,
page_category=bid_request.page_category,
format=bid_request.format,
)
# 2. Score each ad (relevance × bid price)
scored_ads = [
(ad, predict_ctr(ad, bid_request) * ad.max_cpm)
for ad in eligible_ads
]
# 3. Second-price auction (Vickrey): winner pays second-highest bid + $0.01
scored_ads.sort(key=lambda x: x[1], reverse=True)
winner, winning_score = scored_ads[0]
clearing_price = scored_ads[1][1] + 0.01 if len(scored_ads) > 1 else scored_ads[0][1]
# 4. Log impression asynchronously
event_bus.publish("impression", {
"ad_id": winner.id, "user_id": bid_request.user_id,
"price": clearing_price, "timestamp": time.time()
})
return winner.creativeCTR prediction: Logistic regression or gradient boosted trees on features (user segments, ad creative, hour of day, device type). Served from model cache (< 1ms inference).
Frequency capping: Track per-user ad exposure in Redis. INCR user:{uid}:ad:{ad_id}:day with 24h TTL.
34. Design a logging and analytics pipeline
Requirements: Ingest billions of events/day, real-time dashboards, historical queries, low-cost storage.
Lambda architecture:
- Speed layer: Kafka → Flink/Spark Streaming → Redis/Druid (real-time metrics, ~1 min latency)
- Batch layer: Kafka → S3 → Spark (historical processing, accurate, higher latency)
- Serving layer: Druid + ClickHouse for OLAP queries across both layers
Modern Kappa architecture (simpler, Kafka-native):
- Everything is a stream. Reprocess historical data by replaying Kafka from offset 0.
- Kafka retention: 7–30 days for hot data, archive to S3 for cold.
ClickHouse for analytics (extremely fast OLAP):
-- ClickHouse table for events
CREATE TABLE events (
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
session_id String,
properties Map(String, String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, user_id, event_time);
-- Query: DAU for last 30 days
SELECT toDate(event_time) AS date, uniq(user_id) AS dau
FROM events
WHERE event_type = 'session_start'
AND event_time >= now() - INTERVAL 30 DAY
GROUP BY date ORDER BY date;
-- Runs in < 1 second on billions of rowsEvent schema design (use a consistent schema across all events):
{
"schema_version": "1.0",
"event_id": "uuid",
"event_type": "page_view",
"timestamp": "2024-01-15T10:30:00Z",
"user_id": 12345,
"session_id": "abc123",
"properties": {
"page": "/product/123",
"referrer": "google.com",
"device": "mobile"
}
}35. How do you design for data privacy and GDPR compliance?
Right to be forgotten:
def delete_user_data(user_id: int):
# Hard delete PII from all primary stores
db.execute("DELETE FROM users WHERE id = ?", user_id)
db.execute("DELETE FROM user_profiles WHERE user_id = ?", user_id)
# Anonymize historical events (keep for analytics, remove PII)
db.execute(
"UPDATE events SET user_id = NULL, ip_address = NULL WHERE user_id = ?",
user_id
)
# Purge from caches
redis.delete(f"user:{user_id}:*") # scan and delete all user keys
# Purge from search indexes
elasticsearch.delete_by_query({"term": {"user_id": user_id}})
# Queue purge from backups (harder — flag for exclusion in backup restore)
deletion_log.record(user_id, requested_at=datetime.utcnow())The backup problem: You can't easily delete from backups. Solution: encrypt user data with a per-user encryption key. To "delete" the user, delete their key. Their data in backups becomes unreadable.
Data minimization: Only collect what you need. Use field-level encryption for sensitive data (SSN, full credit card number).
Consent management: Store explicit consent records with timestamp and version of privacy policy. Gate data processing on consent scope.
36. Design a job scheduling system (like cron at scale)
Requirements: Schedule jobs to run at specific times or intervals. Distributed, fault-tolerant, exactly-once execution.
# Job definition
{
"job_id": "send-weekly-digest",
"cron_expression": "0 9 * * MON", # every Monday at 9am
"timezone": "America/New_York",
"handler": "digest_service.send_weekly",
"timeout_sec": 300,
"max_retries": 3,
"concurrency_policy": "forbid" # don't run if previous still running
}Core algorithm:
- 1Leader election (Zookeeper/etcd) — only one scheduler node triggers jobs
- 2Scan next-run-times every 10 seconds, enqueue due jobs to Kafka/SQS
- 3Workers consume from queue, execute job, report result
- 4Distributed locking per job prevents duplicate execution (even with multiple workers)
def check_and_schedule_jobs():
now = datetime.utcnow()
due_jobs = db.query(
"SELECT * FROM jobs WHERE next_run_at <= ? AND status = 'active'",
now
)
for job in due_jobs:
# Atomic claim: only one scheduler picks up this job
updated = db.execute(
"UPDATE jobs SET status = 'scheduled', next_run_at = ? "
"WHERE job_id = ? AND status = 'active' AND next_run_at <= ?",
compute_next_run(job.cron_expression), job.job_id, now
)
if updated.rowcount == 1:
queue.enqueue("job_executions", {"job_id": job.job_id, "trigger_time": now})Idempotent execution: Pass trigger_time as idempotency key. If a job runs twice for the same trigger time, the second run is a no-op.
37. Design a search engine (like Elasticsearch)
Inverted index — the core data structure:
Document 1: "the quick brown fox"
Document 2: "the lazy brown dog"
Inverted index:
"the" → [1, 2]
"quick" → [1]
"brown" → [1, 2]
"fox" → [1]
"lazy" → [2]
"dog" → [2]
Query "brown fox" → intersection of [1,2] and [1] = [1]from collections import defaultdict
import re
class InvertedIndex:
def __init__(self):
self.index: dict[str, set[int]] = defaultdict(set)
self.documents: dict[int, str] = {}
def tokenize(self, text: str) -> list[str]:
# Lowercase, split on non-alpha, remove stopwords
stopwords = {"the", "a", "an", "is", "are", "was"}
tokens = re.findall(r'[a-z]+', text.lower())
return [t for t in tokens if t not in stopwords]
def add_document(self, doc_id: int, content: str):
self.documents[doc_id] = content
for token in set(self.tokenize(content)):
self.index[token].add(doc_id)
def search(self, query: str) -> list[int]:
tokens = self.tokenize(query)
if not tokens:
return []
result = self.index.get(tokens[0], set())
for token in tokens[1:]:
result = result & self.index.get(token, set())
return sorted(result)TF-IDF scoring (term frequency × inverse document frequency):
- TF: how often term appears in document (normalized by doc length)
- IDF: log(total_docs / docs_containing_term) — rewards rare terms
- Higher score = more relevant
Sharding: Documents distributed across shards by doc_id hash. Queries fan out to all shards, results merged and re-ranked. Elasticsearch calls shards "primary shards."
Real-time indexing: Write to Kafka → indexer workers update index segments → Lucene segments merged in background (segment compaction, similar to LSM tree).
38. Design a feature flag system
Requirements: Enable/disable features per user, cohort, or percentage. Instant rollout/rollback. No code deploys needed.
class FeatureFlagService:
def __init__(self, redis_client, db):
self.redis = redis_client
self.db = db
self.local_cache = {} # in-process cache, TTL 30s
def is_enabled(self, flag_name: str, user_id: int, context: dict = None) -> bool:
flag = self._get_flag(flag_name)
if not flag:
return False # unknown flags default to off
# Killswitch: globally disabled
if not flag["enabled"]:
return False
# User-specific override
if user_id in flag.get("user_overrides", {}):
return flag["user_overrides"][user_id]
# Cohort-based (e.g., beta users, employees)
if context and context.get("cohort") in flag.get("allowed_cohorts", []):
return True
# Percentage rollout (deterministic: same user always gets same result)
if "rollout_percentage" in flag:
bucket = hash(f"{flag_name}:{user_id}") % 100
return bucket < flag["rollout_percentage"]
return flag.get("default", False)
def _get_flag(self, name: str) -> dict:
# L1: in-process cache
if name in self.local_cache and not self._is_stale(name):
return self.local_cache[name]
# L2: Redis
cached = self.redis.get(f"flag:{name}")
if cached:
flag = json.loads(cached)
self.local_cache[name] = flag
return flag
# L3: DB
flag = self.db.get_flag(name)
if flag:
self.redis.setex(f"flag:{name}", 30, json.dumps(flag))
self.local_cache[name] = flag
return flagPush invalidation: When a flag is updated, push invalidation event via Redis pub/sub to clear all in-process caches immediately. This ensures rollback is instant, not limited to the 30-second TTL.
39. What are the most common system design mistakes?
1. Over-engineering from the start
Starting with microservices, Kafka, and 3 data stores before you have 100 users. Start monolithic, extract services when you have specific, measured scaling problems.
2. Not clarifying requirements
Spending 45 minutes designing a globally distributed system when the interviewer only needed a single-region service at 10K RPS.
3. Ignoring the data model
Components and architecture without a concrete schema. The schema drives every query, every index, every scaling decision.
4. Forgetting failure modes
What happens when the cache is cold? When the DB primary fails? When a Kafka consumer lags? Every system must have an answer.
5. Premature optimization of the wrong bottleneck
Adding a cache before profiling to confirm the DB is actually the bottleneck. Adding read replicas when the issue is N+1 queries.
6. Ignoring operational concerns
How do you deploy it? How do you monitor it? How do you debug it at 3am? A system that can't be operated is not a real system.
7. Designing for average case, not tail latency
Average latency 20ms, p99 = 5 seconds. The p99 is the experience of 1% of users — at 10M RPS that's 100K users per second having a bad time.
40. How do you talk about trade-offs in a system design interview?
This is the most important meta-skill. Interviewers at FAANG are explicitly evaluating whether you can hold two conflicting ideas simultaneously and reason about which is better given specific constraints.
Framework for discussing any trade-off:
- 1Name both sides explicitly: "We can do X or Y"
- 2State what each optimizes for: "X gives us lower latency but higher write amplification; Y gives us simpler operations but slower reads"
- 3Tie to requirements: "Given that this is a read-heavy system with 100:1 read/write ratio, X is the better fit"
- 4Acknowledge the cost: "We accept the write amplification because..."
Common trade-off pairs to know cold:
| Trade-off | Choose A when... | Choose B when... |
|-----------|-----------------|-----------------|
| SQL vs NoSQL | Complex queries, transactions | Write scale, flexible schema |
| Strong vs eventual consistency | Financial data, inventory | Social feeds, product catalogs |
| Fan-out on write vs read | Read-heavy, many followers | Write-heavy, celebrities (many followers) |
| Normalization vs denormalization | Write-heavy, storage cost | Read-heavy, query performance |
| Monolith vs microservices | Small team, early stage | Large team, independent scaling needs |
| Push vs pull (notifications) | Fewer receivers, real-time | Many receivers, polling acceptable |
| Cache-aside vs write-through | Tolerates stale data | Needs fresh data on every read |
| Synchronous vs async processing | User needs immediate result | User tolerates eventual result |
The answer interviewers want: Not "X is better than Y" but "X and Y are better in different contexts, and here are the specific signals I'd look for to decide."
Quick Reference: Numbers Every Engineer Must Know
Latency Throughput
───────────────────────────── ─────────────────────────────────
L1 cache: 0.5 ns Single-core compute: ~1B ops/sec
L2 cache: 5 ns Redis: ~1M ops/sec
RAM: 100 ns Postgres writes: ~5K-10K/sec
SSD random read: 100 µs Kafka: ~1M msgs/sec
Network (same DC): 0.5 ms S3 GET: ~5,500 req/s
Network (cross): 100 ms CDN edge: ~100K req/s
Storage
─────────────────────────────────────────
1B users × 1KB profile = 1 TB
1B rows × 100 bytes = 100 GB
1M req/sec × 1KB/req = 1 GB/sec ingress
100 bytes/msg × 1M msg/s = 100 MB/s Kafka throughputFinal Advice
System design interviews reward candidates who can structure ambiguity. The engineer who asks three sharp clarifying questions before drawing a single box will consistently outperform the one who launches immediately into a fully-detailed architecture.
Practice the framework until the structure is automatic. Then the cognitive load of "what to say next" drops away, and you can focus on the actual trade-offs — which is where the real signal lives.
One system to practice that covers nearly every component: design Uber. You will touch real-time location updates (WebSocket, geospatial indexing), matching algorithm (geohash, priority queues), payment processing (idempotency, saga), surge pricing (time-series aggregation), and notifications (push, SMS). Design it once completely, then design it again a week later without looking at your notes. If you can do it cleanly in 45 minutes, you are ready.