System design fundamentals
Requirements, rough numbers, the standard parts and where systems break.
Why it matters
Writing code that works on your laptop is one skill. Designing a system that serves a million people, survives a server dying and doesn't bankrupt the company is another. It's the skill senior engineers are hired for and the core of system design interviews. It rests on a few habits: ask what's really needed, do rough math before building, and know the standard parts and their trade-offs.
What you'll be able to do
- Turn a vague product idea into requirements, including how fast, how big and how reliable
- Estimate traffic, storage and server counts with rough numbers
- Name the standard building blocks and when each one is needed
- Work out availability for parts in a chain and in parallel, and spread data across servers with consistent hashing
What it covers
- Start with requirements
- A back-of-the-envelope estimate
- The standard parts
- What a cache does to the database
- When one database isn't enough
- Consistency trade-offs
- Why modulo sharding hurts
- Don't build for scale you don't have
- Replicas lag
Start with requirements
Before designing a house, an architect asks how many people will live there and whether it has to survive hurricanes. Before drawing boxes, pin down two kinds of requirements:
- Functional: what it does. "Users upload a photo; followers see it in their feed."
- Non-functional: how well. Latency, meaning how long people wait ("feed loads in under 200 ms"), scale ("50 million daily users"), availability ("99.9% of the time"), consistency ("a like may take a few seconds to show"), cost and security.
The non-functional ones drive the design. A feed for 1,000 people is one server and a database. For 50 million, it's a different system. Ask, and write the answers down.
Rough numbers
Planning a party, you work out guests times slices before ordering pizza. Back-of-the-envelope math is the same habit, and it tells you which problems are real:
- Requests per second: daily users × requests per user ÷ 86,400 seconds. Peaks are often 2 to 3 times the average.
- Storage: items per day × size × how long you keep them.
- Servers: peak requests per second ÷ what one server handles, plus spares.
Know a few speeds by their order of size:
| operation | about |
|---|---|
| read from memory | 100 nanoseconds |
| read from an SSD | 100 microseconds |
| round trip inside a data center | 0.5 milliseconds |
| round trip across an ocean | 100+ milliseconds |
| one LLM reply | 1 to 30 seconds |
Think of memory as the pen on your desk, disk as a cabinet down the hall, and the network as a letter across town. Memory is about 1,000 times faster than disk, and the network is slower still. That's why caches exist, and why you avoid many sequential network calls on one request.
A back-of-the-envelope estimate
Say a shop has 200,000 daily users who make 25 requests each. Is that a big system? Rough arithmetic answers in a minute.
users = 200_000 requests_each = 25 peak_factor = 3 per_server = 500 # requests a second one server handles order_size_kb = 2 orders_a_day = 8_000 average = users * requests_each / 86_400 peak = average * peak_factor servers = int(peak // per_server) + 1 + 1 # round up, plus one spare print("average requests a second:", round(average)) print("peak requests a second:", round(peak)) print("servers:", servers) print("order storage a year (GB):", round(orders_a_day * order_size_kb * 365 / 1_000_000, 1))
About 170 requests a second at peak is two small servers, and a few GB of orders a year fits one database. So this design doesn't need sharding or a fleet of services. The numbers save you from building for problems you don't have.
Show the answer
About 2,300 per second
200 million requests ÷ 86,400 seconds ≈ 2,300 per second. Plan for a peak of maybe 5,000 to 7,000.
The standard parts
Most systems are built from the same parts, the way most restaurants have a host, waiters, a ledger and a storeroom. The host at the door is your load balancer, sending each guest to a free waiter. Here's the full list:
| part | job | add it when |
|---|---|---|
| load balancer | spreads requests across servers | you have more than one server |
| stateless app servers | run your code; keep no user state in memory | always: it lets you add servers freely |
| database | the source of truth | always |
| read replicas | copies of the database for reads | reads overwhelm one database |
| cache | keeps hot data in memory | the same data is read again and again |
| queue and workers | do slow work later, outside the request | work takes seconds, or can be retried |
| object storage | files: images, videos, backups | you store files, not only rows |
| CDN | serves static files from near the user | users are spread around the world |
Stateless servers are the key idea. Any waiter can serve any table if orders live in a shared system instead of one waiter's head. If any server can handle any request, because sessions live in the database or a cache, scaling is "add more servers behind the load balancer".
In plain words: add a part when you have the problem it solves, and not before.
What a cache does to the database
A cache in front of the database takes most reads away from it. Watch how fast the load falls as the hit rate climbs.
reads = 2000 # reads a second for hit_rate in [0, 0.5, 0.9, 0.99]: to_database = reads * (1 - hit_rate) print(f"hit rate {hit_rate:>4.0%}: {to_database:>6.0f} reads a second reach the database")
Going from 90% to 99% cuts the database's load by ten times, even though the hit rate only moved nine points. It works the other way as well: if a cache restarts empty, all that load lands on the database at once.
When one database isn't enough
First add read replicas, which are like photocopies of a master ledger. Replicas lag a little behind, so a user might not see their own write for a moment; read from the primary right after a write if that matters.
Next comes sharding: splitting data across several databases by a key, such as user id. A library might keep surnames A to H in one branch and I to Z in another. The simple way, hash(key) % n, has a catch: when n changes, almost every key moves. Consistent hashing places servers and keys on a ring, and each key goes to the next server clockwise. Adding a server then moves only about 1/n of the keys.
Availability math
Availability is the share of time a system works. Old holiday lights on one wire go dark when a single bulb fails. Parts in a chain, all needed, behave the same way, and their availabilities multiply: a 99.9% load balancer, 99.9% servers and a 99.9% database give 99.7%.
Carrying two flashlights is different. You're only in the dark if both die. Redundant copies, any one enough, fail only if all fail: two 99% servers give 1 − 0.01² = 99.99%.
So every part that's needed and has no backup, a single point of failure, caps the whole system. Find them, and add a spare where the cost is worth it.
Chains multiply, so every dependency costs you. Copies fail only if they all fail at once.
Consistency trade-offs
Say two bank branches lose their phone line. They can refuse withdrawals until it's back, or keep paying out and risk mistakes. When the network between data centers breaks, a system must make the same choice: refuse some requests to stay consistent, or keep answering with possibly stale data to stay available. That's the CAP theorem in practice. Bank balances choose consistency. Like counts choose availability, with eventual consistency: copies agree soon, not instantly.
a = 0.99 print(round(1 - (1 - a) ** 3, 6))
Show the answer
0.999999
All three must fail together for an outage, which is 0.01³, so availability is 1 − 0.000001 = 0.999999. Redundancy turns two nines into six.
Why modulo sharding hurts
Count how many of 1,000 keys move when a fourth server becomes five.
import hashlib def h(text): return int(hashlib.sha256(text.encode()).hexdigest()[:8], 16) keys = [f"user-{i}" for i in range(1000)] before = {k: h(k) % 4 for k in keys} after = {k: h(k) % 5 for k in keys} moved = sum(before[k] != after[k] for k in keys) print(f"{moved} of 1000 keys moved")
About 80% move, so adding a server means copying most of the data. Note the use of hashlib: Python's own hash() of a string changes every time the program starts, so it can't be used to place data.
hash(), to choose which server holds a key?Show the answer
hash() of a string changes each time Python starts, so keys would move after a restart
Placement must be stable across processes and restarts. A real hash function gives the same answer every time.
Don't build for scale you don't have
You don't build a stadium for a backyard game. Ask how big it needs to be before designing anything. And never use Python's hash() to place data across servers: it changes each time Python starts.
Replicas lag
A photocopy can't include what was written after it was made. A replica may be a moment behind, so someone can save a change and not see it on their next read.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why should app servers be stateless?