System design is about making the right tradeoffs at scale. This cheat sheet covers every major concept you need — from load balancing to database sharding — with interview frameworks and real-world patterns.
Quick reference
| Concept | One-line summary |
|---|---|
| Load balancer | Distributes traffic across multiple servers |
| Horizontal scaling | Add more machines (scale out) |
| Vertical scaling | Add more power to one machine (scale up) |
| CDN | Caches static assets geographically close to users |
| Cache | Stores results of expensive operations in fast memory |
| Message queue | Decouples producers from consumers asynchronously |
| Database sharding | Splits data across multiple database instances |
| Database replication | Copies data to multiple databases for reads/failover |
| API gateway | Single entry point for all client requests |
| Rate limiting | Controls request frequency to prevent abuse |
| Circuit breaker | Stops calling a failing service to allow recovery |
| Consistent hashing | Distributes data across nodes with minimal reshuffling |
Interview framework: how to approach any system design question
Use this step-by-step structure for every system design interview:
1. Clarify requirements (5 min)
- Functional: what does the system DO?
- Non-functional: scale, latency, availability, consistency
2. Estimate scale (2 min)
- Users: DAU, requests/sec
- Data: storage per day/year, bandwidth
3. High-level design (10 min)
- Draw boxes: clients, servers, databases, caches, queues
- Identify the core data flow
4. Deep dive (15 min)
- Pick the hardest part and go deep
- Databases, APIs, algorithms
5. Identify bottlenecks (5 min)
- Single points of failure, hot spots, scaling limits
- Propose solutions
Back-of-envelope estimation cheat sheet
| Metric | Rough value |
|---|---|
| 1 million DAU | ~12 requests/sec average |
| 100 million DAU | ~1,200 requests/sec |
| 1 KB per request | 1 GB = 1M requests |
| Read:write ratio | Usually 10:1 to 100:1 |
| MySQL row | ~100 bytes average |
| Image thumbnail | ~50 KB |
| Video (1 min, 480p) | ~20 MB |
| SSD read latency | ~0.1 ms |
| RAM read latency | ~0.0001 ms |
| Network round trip (same DC) | ~0.5 ms |
| Network round trip (cross-continent) | ~100–200 ms |
Scalability
Horizontal vs vertical scaling
Vertical (scale up) Horizontal (scale out)
──────────────────── ──────────────────────
Bigger machine More machines
Simpler to implement Requires coordination
Limited ceiling Theoretically unlimited
Single point of failure Fault tolerant
Expensive at high end Commodity hardware
Stateless vs stateful services
Stateless — easier to scale:
- Any server can handle any request
- Session data lives in shared store (Redis, DB)
- Works with load balancers out of the box
Stateful — harder to scale:
- Client must reconnect to the same server (sticky sessions)
- Required for WebSockets, some game servers
- Use consistent hashing to route to correct node
Load balancing
Load balancing algorithms
| Algorithm | How it works | Best for |
|---|---|---|
| Round robin | Each server in turn | Equal capacity servers |
| Weighted round robin | More requests to stronger servers | Mixed capacity |
| Least connections | Server with fewest active connections | Long-lived connections |
| IP hash | Hash of client IP → server | Sticky sessions |
| Random | Random server selection | Simple, low traffic |
| Least response time | Fastest responding server | Latency-sensitive |
Layer 4 vs Layer 7 load balancing
Layer 4 (Transport) Layer 7 (Application)
──────────────────── ──────────────────────
Routes by IP + port Routes by URL, headers, cookies
Faster (no parsing) Slower (inspects content)
Can't route by path Can do /api → servers A, /web → servers B
Lower cost Content-based routing, SSL termination
Health checks
# Nginx upstream with health check
upstream backend {
least_conn;
server 10.0.0.1:8080 weight=3;
server 10.0.0.2:8080 weight=1;
server 10.0.0.3:8080 backup;
}
Caching
Cache strategies
Cache-aside (lazy loading) — most common:
1. App checks cache → cache miss
2. App reads from DB
3. App writes result to cache
4. Next request → cache hit
Pros: Only caches what's needed
Cons: Cache miss on first request, stale data possible
Write-through — keeps cache in sync:
1. App writes to cache AND DB together
Pros: Data always fresh in cache
Cons: Write penalty, caches data never read
Write-behind (write-back) — fast writes:
1. App writes to cache only
2. Cache asynchronously flushes to DB
Pros: Very fast writes
Cons: Data loss risk if cache crashes before flush
Read-through — cache fetches from DB:
1. App only talks to cache
2. On miss: cache fetches from DB automatically
Pros: Simpler app code
Cons: Extra hop, cache tied to DB schema
Cache eviction policies
| Policy | When to evict | Use case |
|---|---|---|
| LRU (Least Recently Used) | Oldest unused item | General purpose |
| LFU (Least Frequently Used) | Item accessed fewest times | Popular content stays |
| FIFO | First item added | Simple, predictable |
| Random | Random item | Approximate LRU, fast |
| TTL | Item past its expiry | Time-sensitive data |
What to cache
✅ Good candidates:
- Session data
- User profiles (read-heavy)
- Computed results (expensive queries)
- HTML fragments
- API responses from slow third parties
- Database query results
❌ Bad candidates:
- Frequently updated data (payment info, inventory)
- User-specific secure data (without careful key design)
- Very large objects (wastes cache memory)
Redis key design
# Pattern: resource:id:field
user:42:profile
session:abc123
rate:user:42:2024-01-15
# SET with TTL
SET user:42:profile '{"name":"Alice"}' EX 3600
# Atomic increment for rate limiting
INCR rate:user:42:2024-01-15:13
EXPIRE rate:user:42:2024-01-15:13 60
Databases
SQL vs NoSQL
| Factor | SQL | NoSQL |
|---|---|---|
| Schema | Fixed | Flexible |
| Joins | Native | Manual or none |
| Transactions | ACID | BASE (usually) |
| Scale | Vertical (primarily) | Horizontal |
| Consistency | Strong | Eventual (often) |
| Best for | Relations, reporting | Scale, flexible schema |
| Examples | PostgreSQL, MySQL | MongoDB, Cassandra, DynamoDB |
Database replication
Primary-replica (master-slave):
┌─────────┐ sync/async ┌─────────┐
│ Primary │ ──────────────▶ │ Replica │ (reads)
│ (writes)│ └─────────┘
└─────────┘ ──────────────▶ ┌─────────┐
│ Replica │ (reads)
└─────────┘
Sync replication: consistent, higher write latency
Async replication: faster writes, possible data loss on crash
Database sharding
Sharding strategies:
Range-based: user_id 1-1M → shard 1, 1M-2M → shard 2
Pros: simple. Cons: hot spots if range is popular.
Hash-based: shard = hash(user_id) % num_shards
Pros: even distribution. Cons: resharding is painful.
Directory: lookup table maps key → shard
Pros: flexible. Cons: extra lookup, single point of failure.
Geo-based: EU users → EU shard, US users → US shard
Pros: low latency. Cons: complex queries across shards.
Database indexing
-- B-tree index: range queries, equality
CREATE INDEX idx_users_email ON users(email);
-- Composite index (order matters — leftmost prefix rule)
CREATE INDEX idx_posts_user_date ON posts(user_id, created_at);
-- Uses: WHERE user_id = 1 ✅
-- Uses: WHERE user_id = 1 AND created_at > '2024-01-01' ✅
-- Skips: WHERE created_at > '2024-01-01' ❌ (no user_id prefix)
-- Covering index (all needed columns in index)
CREATE INDEX idx_covering ON posts(user_id, created_at, title);
-- Partial index (index a subset of rows)
CREATE INDEX idx_active ON users(email) WHERE active = true;
CAP theorem
Every distributed system can only guarantee 2 of 3:
Consistency
/\
/ \
/ \
/ CP \
/ \
/----AP----\
/ \
Availability ── Partition
Tolerance
C — Every read sees the most recent write
A — Every request gets a response (not guaranteed to be latest)
P — System works despite network partitions
Real-world picks:
| System | Chooses | Trade-off |
|---|---|---|
| PostgreSQL | CA | Assumes no partitions (single node) |
| HBase / Zookeeper | CP | Consistency over availability |
| Cassandra / DynamoDB | AP | Availability over consistency |
| MongoDB (default) | AP | Tunable via write concern |
PACELC extension:
- Even without partitions: choose Latency vs Consistency
- DynamoDB: PA/EL (Available during partition, Low latency else)
- BigTable: PC/EC (Consistent in both cases)
Message queues
When to use a message queue
✅ Use queues for:
- Decoupling services (email sending, notification delivery)
- Smoothing traffic spikes (order processing)
- Background jobs (image resizing, report generation)
- Broadcast events to multiple consumers (fan-out)
- Guaranteed delivery with retry
❌ Don't use for:
- Synchronous request/response (user waiting for result)
- Simple one-off background tasks (cron is simpler)
Queue comparison
| Feature | Redis (Lists) | RabbitMQ | Kafka | SQS |
|---|---|---|---|---|
| Persistence | Optional (AOF/RDB) | Yes | Yes (log) | Yes |
| Message ordering | FIFO | Per-queue | Per-partition | Best-effort |
| Throughput | High | Medium | Very high | High |
| Retention | Until consumed | Until consumed | Configurable (days) | Up to 14 days |
| Replay | No | No | Yes (seek offset) | No |
| Use case | Simple queues | Complex routing | Event streaming | Managed AWS |
Producer-consumer pattern
# Producer (Python + Redis)
import redis, json
r = redis.Redis()
def enqueue_email(to, subject, body):
job = {"to": to, "subject": subject, "body": body}
r.rpush("email_queue", json.dumps(job))
# Consumer
def process_emails():
while True:
_, data = r.blpop("email_queue") # blocking pop
job = json.loads(data)
send_email(job["to"], job["subject"], job["body"])
API design
REST design principles
Resource-based URLs:
GET /users → list users
POST /users → create user
GET /users/42 → get user 42
PUT /users/42 → replace user 42
PATCH /users/42 → partial update
DELETE /users/42 → delete user 42
Nested resources:
GET /users/42/posts → posts by user 42
POST /users/42/posts → create post for user 42
Filtering, sorting, pagination:
GET /posts?status=published&sort=-created_at&page=2&limit=20
Pagination strategies
| Strategy | Query | Pros | Cons |
|---|---|---|---|
| Offset | LIMIT 20 OFFSET 100 |
Simple, random access | Slow at large offsets, inconsistent on inserts |
| Cursor | WHERE id > :cursor LIMIT 20 |
Fast, stable | No random access, cursor must be stable |
| Page token | Opaque token encodes cursor | Flexible encoding | Opaque to client |
-- Cursor (keyset) pagination — fast even at millions of rows
SELECT * FROM posts
WHERE (created_at, id) < (:last_ts, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Rate limiting algorithms
| Algorithm | How it works | Pros | Cons |
|---|---|---|---|
| Fixed window | Count resets every N seconds | Simple | Burst at window boundary |
| Sliding window log | Log each request timestamp | Accurate | High memory |
| Sliding window counter | Weighted previous + current window | Balanced | Slight inaccuracy |
| Token bucket | Tokens refill at fixed rate | Allows bursts | More complex |
| Leaky bucket | Requests drip out at fixed rate | Smooth output | Drops bursts |
# Token bucket in Redis (Lua script for atomicity)
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- tokens per second
local capacity = tonumber(ARGV[2]) -- max burst
local now = tonumber(ARGV[3]) -- current timestamp ms
local last = tonumber(redis.call('hget', key, 'ts') or now)
local tokens = tonumber(redis.call('hget', key, 'tokens') or capacity)
-- Refill
tokens = math.min(capacity, tokens + rate * (now - last) / 1000)
if tokens >= 1 then
tokens = tokens - 1
redis.call('hset', key, 'ts', now, 'tokens', tokens)
return 1 -- allowed
else
return 0 -- rate limited
end
Reliability patterns
Circuit breaker
States:
CLOSED → requests flow normally
OPEN → requests fail fast (no calls to downstream)
HALF-OPEN → let one request through to test recovery
Transition:
CLOSED → too many failures → OPEN
OPEN → timeout expires → HALF-OPEN
HALF-OPEN → success → CLOSED | failure → OPEN
Retry with exponential backoff
import time, random
def call_with_retry(fn, max_retries=3, base_delay=0.5, max_delay=10):
for attempt in range(max_retries + 1):
try:
return fn()
except TransientError as e:
if attempt == max_retries:
raise
# Exponential backoff with jitter
delay = min(base_delay * (2 ** attempt) + random.random(), max_delay)
time.sleep(delay)
Idempotency
Make operations safe to retry:
# Idempotency key in header (client generates UUID per operation)
POST /orders
Idempotency-Key: 7d8f3e2a-1b4c-4d5e-9f0a-2b3c4d5e6f7a
# Server logic
def create_order(idempotency_key, data):
existing = cache.get(f"idempotency:{idempotency_key}")
if existing:
return existing # return cached response, don't process again
result = process_order(data)
cache.set(f"idempotency:{idempotency_key}", result, ex=86400)
return result
Content delivery networks (CDNs)
What CDNs cache
✅ Cache with CDN:
- Images, videos, CSS, JS bundles
- HTML pages (for SSG sites)
- API responses (with Cache-Control headers)
- Fonts
❌ Don't cache:
- Personalised content (without Vary header)
- Real-time API endpoints
- Payment/checkout pages
Cache-Control headers
# Cache everywhere for 1 hour
Cache-Control: public, max-age=3600
# CDN caches, but browser doesn't
Cache-Control: public, s-maxage=86400, max-age=0
# Never cache (auth, real-time)
Cache-Control: no-store
# Revalidate before serving stale
Cache-Control: no-cache
# Stale-while-revalidate (serve stale, refresh in background)
Cache-Control: max-age=60, stale-while-revalidate=600
Consistent hashing
Used for distributing data across nodes with minimal reshuffling when nodes are added/removed:
Traditional hashing: key → hash(key) % N
Adding a node changes almost all mappings!
Consistent hashing: key → position on a ring
key maps to the next node clockwise on ring
Adding a node only affects its neighbours
With virtual nodes (vnodes):
- Each physical node gets multiple positions on the ring
- Better distribution, handles heterogeneous node sizes
- Used by: Cassandra, DynamoDB, Riak
Common system design problems
URL shortener (like bit.ly)
Core requirements:
- shorten(long_url) → short_code
- redirect(short_code) → long_url
Design decisions:
1. ID generation: auto-increment DB ID → base62 encode (a-z, A-Z, 0-9)
1000000 → "4c92" (7 chars covers 62^7 ≈ 3.5 trillion URLs)
2. Storage: SQL (MySQL) — one row per URL
(id BIGINT, long_url TEXT, created_at, user_id, clicks)
3. Redirect: 301 (permanent, browser caches) vs 302 (temporary, tracks clicks)
Use 302 if you want accurate click analytics.
4. Cache: Redis — cache hot short codes (top 20% generate 80% of traffic)
Key: short_code, Value: long_url, TTL: 24h
5. Scale: sharding by short_code prefix, read replicas for lookups
Rate limiter
Requirements: allow N requests per user per minute
Design:
1. Storage: Redis per user key with sliding window
2. Key: rate:{user_id}:{window}
3. Algorithm: token bucket or sliding window counter
4. Middleware: API gateway checks limit before routing
5. Response: 429 Too Many Requests + Retry-After header
Feed/timeline (like Twitter)
Fanout-on-write (push):
- When user posts, push to all followers' feeds
- Pros: fast read (pre-computed)
- Cons: slow write for users with millions of followers (celebrities)
Fanout-on-read (pull):
- When user opens feed, fetch from all followees
- Pros: fast write
- Cons: slow read (N queries)
Hybrid (used by Twitter):
- Regular users: fanout-on-write
- Celebrity accounts: fanout-on-read
- Merge at read time
Microservices vs monolith
| Factor | Monolith | Microservices |
|---|---|---|
| Complexity | Simple to start | Complex (networking, service mesh) |
| Deployment | Deploy everything at once | Independent deployment |
| Scaling | Scale everything or nothing | Scale individual services |
| Data | Shared database | Each service owns its data |
| Failures | One bug can crash all | Isolated failures |
| Team size | Works well for small teams | Better for large org with many teams |
| Start here | ✅ Usually yes | When monolith becomes a bottleneck |
Service communication
Synchronous (request/response):
- REST over HTTP — simple, widely supported
- gRPC — binary protocol, great for internal services (strongly typed, faster)
Asynchronous (events):
- Message queue (RabbitMQ, SQS) — point-to-point
- Event streaming (Kafka) — pub/sub with replay
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Over-engineering early | Unnecessary complexity | Start simple, scale when needed |
| No cache invalidation plan | Stale data bugs | Define TTL and eviction strategy upfront |
| Synchronous everything | Latency compounds | Use async for non-critical paths |
| Single database | Bottleneck at scale | Read replicas, then sharding |
| No idempotency | Duplicate operations on retry | Add idempotency keys to writes |
| Forgetting CAP | Wrong consistency model | Know whether you need CP or AP |
| Ignoring the network | Assumes calls are instant | Account for latency, retries, timeouts |
| No circuit breaker | Cascade failures | Add circuit breaker to all external calls |
FAQ
What's the difference between latency and throughput? Latency is the time for one request to complete (ms). Throughput is how many requests the system handles per second (RPS). A fast server (low latency) can still have low throughput if it can only handle one request at a time. You typically improve throughput by parallelism.
When should I use a cache vs a database? Cache for frequently read, rarely changing data where stale results are tolerable. Use the database as the source of truth. Cache is a performance optimisation, not a storage solution.
How do I choose between SQL and NoSQL? Default to SQL (PostgreSQL). Choose NoSQL when: you need flexible/dynamic schema, horizontal write scaling at massive scale (Cassandra), document-oriented data (MongoDB), or your access pattern is purely key-value (DynamoDB/Redis).
What is eventual consistency? Eventual consistency means all replicas will converge to the same value eventually, but at any moment they may return different results. Acceptable for social likes, view counts, shopping cart (with conflict resolution). Not acceptable for bank balances, inventory that must not oversell.
How do I handle database migrations at scale? Zero-downtime migrations: 1) Add new column as nullable. 2) Deploy code that writes to both old and new columns. 3) Backfill old rows. 4) Deploy code that reads from new column. 5) Drop old column. Never rename a column in one step.
What does "sharding" solve that replication doesn't? Replication improves read throughput and availability. Sharding improves write throughput and allows datasets larger than one machine. Use replication first; add sharding only when writes become the bottleneck.