The System Design Method
How to go from a blank page to a design you can defend: pin down the requirements, estimate the load with round numbers, sketch the API and data, start simple, then let the numbers tell you where it breaks.
The 30-second version
Whether it's a design review at work or a system design interview, it starts the same way: someone describes a product in two sentences, and you have an empty whiteboard. There are many good designs and no single right one. What people judge is how you got there: did you ask what matters, check the size of the problem, start simple, and notice what breaks?
The method, as a loop
This whole topic teaches one loop. You'll use it on every case study in this area:
- 1Requirements§02What must it do? How fast, how available, how durable? What's out of scope?
- 2Estimate§03–04How many requests a second at peak? How much data per year? Read-heavy or write-heavy?
- 3API + data model§05Which calls does it answer, with what load each? What is stored, and how is it looked up?
- 4Simplest design, then the bottleneck§06–08Draw the request path. Which box runs out first? Change one thing, check the numbers again.
- 5Failure§09–10For every box: what do users see when it dies, and how does the system recover? Which SLOs and capacity keep it safe?
- 6Write it down§11Requirements, numbers, design, alternatives, risks: a design doc others can challenge.
Two things about the loop matter more than any single step:
- It's a loop, not a checklist. A number from step 2 can send you back to step 1 ("wait, do we really need to keep snippets forever?"). The bottleneck you find in step 4 might change the data model from step 3.
- Start with the simplest design that could work, often one app server and one database, and only add a box when a number or a requirement demands it. A Cache you can't justify is just one more thing that can break.
The running example
To make every step concrete, we'll design one small product all the way through: SnipShare, a site where you paste a piece of text or code and get a short link anyone can open. It's simple enough to hold in your head, but it still needs most of the building blocks: storage, caching, search and a CDN.
Step 1: Pin down the requirements
"Build a snippet-sharing site" sounds clear, until you ask: can people edit a snippet? Do snippets expire? Are there private ones? Is it a hobby project with 1,000 users or a public site with 10 million? Each answer leads to a different design, so asking is part of the work, not a delay before it.
Two kinds of requirement
Functional: what it does
Functional requirements are features, said from the user's side. "A user can paste text and get a link." "Anyone with the link can read the snippet." If one is missing, the product is missing a feature.
Non-functional: how well
Non-functional requirements are qualities: how fast, how often up, whether data may be lost, how fresh reads must be, and what it may cost. They decide the architecture.
Out of scope: what it won't do
A short list of things you've agreed not to build in this design: accounts, comments, analytics. Writing it down stops scope from quietly growing while you design.
Beginners tend to list features and stop. But two products with identical features can need completely different designs because of their non-functional requirements. These are the five that come up every time:
| Quality | The question to ask | SnipShare's answer |
|---|---|---|
| Latency | How fast must a request feel? | Opening a snippet: under 200 ms for most users |
| Availability | What share of the time must it work? | 99.9% (about 43 minutes of downtime a month) |
| Durability | Can we ever lose data that was saved? | No: a saved snippet must never vanish |
| Consistency | After a write, how soon must every reader see it? | The author sees it at once; others within a few seconds is fine |
| Cost | What's the budget, in money and in people? | Small team; prefer managed services over running our own |
"Consistency" here means one specific thing: how stale a read is allowed to be. With copies of the data on several machines, a reader might hit a copy that hasn't received the latest write yet. CAP & PACELC covers this properly; for now, "fresh within a few seconds is fine" is a perfectly good requirement.
Ask the questions that change the design
Not every question is worth asking. A good clarifying question is one whose answer would change a box on the diagram. Click each question to see what it decides:
SnipShare's requirements, written down
After the questions, write a short list everyone can see. This is the version we'll design against for the rest of the topic:
- users can share snippets
- it should be fast
- it should scale
- it should be reliableFunctional
- paste text (up to 1 MB) → get a short link
- anyone with the link can read it
- optional expiry: 1 day / 1 week / never
- search public snippets by keyword
Non-functional
- 10M daily active users, ~10 reads per user per day
- read p99 < 200 ms, write p99 < 1 s
- 99.9% available; saved snippets never lost
- others may see a new snippet up to a few s late
Out of scope (this version)
- accounts and login, comments, edit historyThe left version can't be wrong, which is exactly the problem: it gives you nothing to check a design against. The right one gives you numbers to estimate with (next section), a size limit that affects storage, and a scope line that keeps login out of the discussion.
Step 2: Back-of-the-envelope numbers
The numbers decide the design. 10 requests a second fits on a laptop; 100,000 requests a second needs caches, many servers and probably split-up data. You can't tell which one you're building until you multiply a few round numbers together.
The three facts that make it easy
1. Work in powers of ten. Round every number to 1, 2, 3 or 5 times a power of ten, then multiply the leading digits and add up the zeros. 10 million users × 10 reads each = 107 × 101 = 108 reads a day. Nobody cares that it's really 97 million.
2. A day has 86,400 seconds, and you round that to 105. So to turn "per day" into "per second", divide by 100,000 (move the decimal point five places left). It's about 15% off, which is fine for this purpose.
| Per day | ÷ 105 | ≈ per second |
|---|---|---|
| 1 million (106) | 106 / 105 | 10 |
| 100 million (108) | 108 / 105 | 1,000 |
| 1 billion (109) | 109 / 105 | 10,000 |
3. Byte sizes go up by 1,000 each step. For estimates, use powers of ten: 1 KB = 103 bytes, 1 MB = 106, 1 GB = 109, 1 TB = 1012, 1 PB = 1015. (Strictly, a KiB is 1,024 bytes; at this precision the difference doesn't matter.)
Average vs peak
Traffic is never spread evenly over the day. People use SnipShare during working hours, a link gets shared on a forum, Monday morning is busier than Sunday night. The average QPS (queries, or requests, per second) tells you how much work there is in total; the peak tells you how many servers you need, because servers have to survive the busiest minute, not the average one.
The usual shortcut is a peak factor: peak QPS = average QPS × 2 to 10. Consumer products often use 2–3 for normal daily swings; something with bursty events (a ticket sale, a sports final) can be 10× or more.
Worked example: SnipShare
From the requirements: 10 million daily active users (DAU), each reads about 10 snippets a day, and about 1 in 10 users creates one snippet a day. The average snippet is about 10 KB. All numbers are approximate.
Requests per day
Reads: 107 users × 10 = 108/day
Writes: 107 × 0.1 = 106/day
Read:write ratio: 108 / 106 = 100 : 1
Per second
Reads: 108 / 105 = ≈ 1,000 QPS avg
Peak (× 3): ≈ 3,000 QPS
Writes: 106 / 105 = ≈ 10 QPS, peak ≈ 30
Storage and bandwidth
Per day: 106 × 10 KB = 1010 B = 10 GB
Per year: 10 GB × 365 ≈ 3.65 TB (call it 4)
Out at peak: 3,000 × 10 KB = 30 MB/s
Now read what the numbers say, because that's the point of doing them:
- 3,000 reads a second is a modest load. A single well-indexed database can often serve a few thousand simple lookups a second (measure your own hardware before you rely on it). Add a cache and there's lots of room. No need for anything exotic on day one.
- 30 writes a second is tiny. Splitting the database across machines to handle writes would be solving a problem SnipShare doesn't have.
- The system is Read-heavy workload (100 reads per write), so effort should go into making reads cheap: caching and copies of the data, not faster writes.
- 4 TB a year is the number to watch. Over five years that's 20 TB. And real systems keep 2–3 copies of every byte (replicas and backups, so one dead disk loses nothing): 20 TB × 3 ≈ 60 TB. That's far more than you'd want on database servers, and a hint to keep snippet bodies somewhere cheap and big, like Object storage (§05), which keeps its own copies for you.
- 30 MB/s is about 240 Mbit/s (×8 to turn bytes into bits). That's fine for a server's network card, and much of it can be served by a CDN anyway.
Your turn
Change the inputs and watch every formula update. Try a 10× bigger product, or make snippets 1 MB instead of 10 KB, and see which number changes the design.
The same estimate in code
Doing it by hand is the skill to build, but a tiny script is handy when the inputs keep changing. This runs as shown and prints the exact (unrounded) numbers. (New to Go? The five-minute primer in Async / Await §02 covers everything used here.)
package main
import "fmt"
func main() {
const (
dau = 10_000_000 // daily active users
readsPerUser = 10 // snippets viewed per user per day
writesPerUser = 0.1 // 1 in 10 users creates one snippet a day
peakFactor = 3 // busiest hour vs the daily average
bytesPerSnip = 10_000 // 10 KB average snippet
secondsPerDay = 86_400
)
readsPerDay := dau * readsPerUser
writesPerDay := dau * writesPerUser
readQPS := float64(readsPerDay) / secondsPerDay // ← per day ÷ seconds per day
writeQPS := writesPerDay / secondsPerDay
storagePerYear := writesPerDay * bytesPerSnip * 365 // bytes
fmt.Printf("reads: avg %.0f QPS, peak %.0f QPS\n", readQPS, readQPS*peakFactor)
fmt.Printf("writes: avg %.0f QPS, peak %.0f QPS\n", writeQPS, writeQPS*peakFactor)
fmt.Printf("storage: %.2f TB per year\n", storagePerYear/1e12)
fmt.Printf("egress: %.0f MB/s at peak\n", readQPS*peakFactor*bytesPerSnip/1e6)
}reads: avg 1157 QPS, peak 3472 QPS
writes: avg 12 QPS, peak 35 QPS
storage: 3.65 TB per year
egress: 35 MB/s at peakCompare the exact output with the mental math: 1,157 vs "≈ 1,000", 35 MB/s vs "≈ 30". Rounding 86,400 to 100,000 makes estimates about 15% low, which is well within "the right order of magnitude". If an estimate and a script disagree by 10×, one of them has a mistake; if they disagree by 15%, both are fine.
Latency numbers to keep in your head
The previous section estimated how much work there is. This one is about how long each piece of work takes, which is what users feel as Latency. A well-known list called "latency numbers every programmer should know" (popularised by Jeff Dean and Peter Norvig in the 2000s) gives the rough cost of common operations. The exact values change with every hardware generation; the ratios have stayed roughly the same, and the ratios are what you design with.
The ladder
Each row is one operation. Toggle between the two scales: the log scale (each grid line is 10× more) lets you see every row; the linear scale shows how they really compare, where everything faster than a network round trip shrinks to an invisible sliver.
The four rungs worth memorising, in round numbers:
| Operation | Roughly | Compared with RAM |
|---|---|---|
| Read from RAM (main memory) | 100 ns | 1× |
| Random read from an SSD | 20–100 µs | about 1,000× slower |
| Round trip to another machine in the same data center | 0.5 ms (500 µs) | about 5,000× slower |
| Round trip to another continent | 80–150 ms | about a million times slower |
(1 ms = 1,000 µs = 1,000,000 ns. The CPU caches topic goes deeper on the fast end of the ladder.)
What the ladder means for a design
A network hop costs more than almost any computation. An app server can do thousands of in-memory operations in the time one call to the database takes. So the first question about a slow request is usually "how many network calls does it make, one after another?", not "is the code efficient?".
You can't beat the speed of light. Light in fiber needs roughly 70–80 ms to cross the Atlantic and come back, no matter how fast your servers are. If SnipShare's servers are in the US and its readers are in Europe, every request pays that before any work happens. The only fix is to put copies of the data closer to the user: a CDN, or servers in more than one region.
Calls in a row add up; calls side by side don't. A request that makes 5 database calls one after another waits 5 round trips. If the calls don't depend on each other, making them at the same time waits about 1. (That's the async patterns topic: Async / Await.)
Spending a latency budget
SnipShare's requirement says "open a snippet in under 200 ms". That 200 ms is a latency budget: every step of the request spends part of it. In the widget above, use the + add to request buttons to build a request one call at a time, and watch the budget bar fill.
Try these and see which ones fit:
- One cache read, then one database query, both in the same data center. (Easily: about 1.5 ms of the 200.)
- Fifty database queries one after another, the classic N+1 queries mistake. (Still fits, barely noticeable at 50 ms, but it grows with the page.)
- Two round trips to another continent. (160–300 ms depending on the distance; the widget uses the 150 ms upper end, so 300 ms: over budget, or nearly, before the server does any work.)
Step 3: API and data model first
It's tempting to start with the exciting part, the diagram. But a diagram without an API is a guess: you don't know which requests are hot, which ones must be fast, or what each box actually stores. Writing the API and the data model first takes five minutes and turns the rest of the design into answering concrete questions.
The API, with numbers on it
Three endpoints cover SnipShare's functional requirements. The trick is the last column: copy the load from your estimate onto each endpoint. It shows at a glance where the design effort must go.
| Endpoint | Does | Peak load (§03) | Must be |
|---|---|---|---|
POST /snippets | Save a snippet, return its short id | ≈ 30 QPS | Durable (never lose it), under 1 s |
GET /snippets/{id} | Return one snippet | ≈ 3,000 QPS | Fast: p99 under 200 ms |
GET /search?q=… | Find public snippets by keyword | ≈ 50 QPS (a guess: 1 in 60 views) | Fresh within minutes is fine |
One endpoint carries about 97% of the traffic (3,000 of about 3,080 requests a second). That single observation tells you most of the design: make GET /snippets/{id} cheap, and the other two can stay simple.
POST /snippets
body: { "content": "fmt.Println(…)", "expiresIn": "1w", "public": true }
reply: 201 Created { "id": "aZ3kQ9", "url": "https://snip.example/aZ3kQ9" }
GET /snippets/aZ3kQ9
reply: 200 OK { "id": "aZ3kQ9", "content": "…", "createdAt": "2026-10-31T09:12:00Z" }
404 Not Found if it never existed or has expiredHow to name resources, pick status codes and paginate search results is its own topic: API Design. For a system design, the list of calls plus their load is enough.
The data model
Next, what do you store? Write it as tables (or documents), with the fields that matter and how big each record is:
CREATE TABLE snippets (
id text PRIMARY KEY, -- the short id in the link, e.g. 'aZ3kQ9'
created_at timestamptz NOT NULL,
expires_at timestamptz, -- NULL = never expires
is_public boolean NOT NULL,
size_bytes integer NOT NULL,
body_key text NOT NULL -- where the content lives in object storage
);
-- ~100 bytes per row: 1M new rows a day ≈ 100 MB/day ≈ 36 GB/yearNotice what's not in the table: the snippet's content. The estimate said bodies add up to about 4 TB a year, while the metadata rows add up to only about 36 GB. Keeping the big, rarely-changing bodies in Object storage (cheap, almost unlimited storage for files, addressed by a key) keeps the database small and fast. The body_key column points to the object.
Access patterns choose the database
An Access pattern is a way the application reads or writes data: "fetch one snippet by id", "search public snippets by word", "delete expired ones". List them, and the storage choice usually becomes obvious:
| Access pattern | How often | Needs |
|---|---|---|
| Get snippet by id | ≈ 3,000/s at peak | A lookup by primary key: any database does this well |
| Create a snippet | ≈ 30/s | A durable insert, and an id that's never handed out twice |
| Search public snippets by keyword | ≈ 50/s | Full-text search: a job for a Search engine, not LIKE '%word%' |
| Delete expired snippets | a background job, hourly | An index on expires_at |
Lookups by key at modest volume mean almost any database works, so pick the one the team already runs well (here, Postgres). "Which database?" is rarely the interesting question in a design; the access patterns are. SQL vs NoSQL walks through matching patterns to databases.
The building blocks
Each block below has its own deep-dive. This page is the index: what it is in one line, the symptom that tells you to reach for it, and what it costs you. Every block fixes one problem and adds at least one new one, usually another thing that can fail or go stale.
Symptom → block
When the numbers or a failure point at a problem, this is the usual first move. It's a starting point, not a rule:
| You notice… | First reach for | Section |
|---|---|---|
| One app server is at 100% CPU | More app servers behind a load balancer | §07 |
| The database is busy with the same reads over and over | A cache | §07 |
| Reads are varied (little repetition) and still too many | Read replicas | §07 |
| Writes, or total data, too big for one database | Shards | §07 |
| Users far away see slow pages; lots of public content | A CDN | §04 |
| A request does slow work the user doesn't need to wait for | A queue + workers | §09 |
| Large files filling the database | Object storage | §05 |
LIKE '%word%' queries scanning everything | A search index | §05 |
SnipShare with every block
Here's where SnipShare could end up: the read path runs top to bottom (browser → CDN → load balancer → app → cache, then the database and object storage on a miss), and saving a snippet also drops a job on the queue so a worker can add it to the search index. It's not where it starts: §07 grows it from one server, one box at a time, and you'll see that at SnipShare's real numbers, several of these boxes aren't needed yet.
Step 4: Start simple, find the bottleneck, change one thing
Start with the simplest thing that works
The first design should be embarrassingly simple: one app server and one database. For SnipShare's real numbers (about 3,000 reads a second at peak) it isn't quite enough, as you'll see below, but it's the right starting point, because every box you add later will then have a reason written next to it.
Starting big has real costs. Each extra box is something to deploy, monitor, pay for and debug at 3 a.m., and the design becomes harder to change. "We might need it later" is how teams end up running a cluster of message brokers for 20 requests a second. (The principle has a name, YAGNI: you aren't gonna need it.)
Draw the request path
The request path is every box one request passes through. For the simple design, reading a snippet goes: browser → app server → database → app server → browser. Now put the load from §03 on each box and compare it with what the box can handle. The box whose load is closest to its limit is the Bottleneck, and it sets the maximum for the whole system, no matter how much room the others have.
A useful way to say "how close to its limit" is Utilization: load ÷ capacity. A server that can handle 5,000 requests a second and gets 4,000 is 80% busy. As a box gets close to fully busy, requests start queueing and latency shoots up long before it hits 100%; Worker Pools shows why. So this topic uses a simple target:
The loop, by hand
- Draw the request path with the load on each box.
- Find the box with the highest utilization.
- Change one thing that relieves that box: a component from §06.
- Recompute the numbers. Repeat until every box is under the target at peak and nothing is a single point of failure.
Changing one thing at a time matters for the same reason it matters when debugging: if you add a cache, three replicas and a CDN at once, you can't tell which one helped, and you're paying for all three.
Try it: grow SnipShare
The simulator starts as the simplest design at 100 QPS. Drag the traffic up. Each time a box turns red, add one component to fix it, and watch where the red moves next. Then switch to 10 : 1 reads (a more write-heavy product) and try again.
Things to notice while you play:
- The fix depends on the cause. A database overloaded by repeated reads wants a Cache. A database overloaded by writes doesn't care about the cache at all.
- Replicas don't add write capacity. Every Replica applies every write the primary does, so with heavy writes the "per node" write load never drops. Only splitting the data into shards divides writes. Sharding explains how, and why it's usually the last thing you reach for.
- You can't add a second app server without a load balancer. Users need one address to connect to; the load balancer is that address, and it spreads requests across the servers behind it.
- The cost line goes up with every box. Watch "machines you run". At 3,000 QPS, a sensible design is a handful of machines. If you're running more, the simulator tells you which ones the numbers don't need.
SnipShare at its real numbers
Back to the estimate: about 3,000 read QPS and 30 write QPS at peak, read-heavy. Running the loop by hand (with the simulator's capacities, which are made up):
| Round | Bottleneck | Change | Why this and not something else |
|---|---|---|---|
| 1 | Database: ≈ 2,970 reads/s + 30 writes/s ≈ 61% busy (the app server is right behind it at 60%) | A cache | Snippets never change and the same popular ones are read over and over: ideal for caching. The database drops to ≈ 7% |
| 2 | App server: 3,000 QPS on one box = 60%, and it's a single point of failure | Load balancer + a second app server | Each server drops to 30%, and one can now die without taking the site down |
| 3 | Nothing over ≈ 30% | Stop | The numbers don't ask for replicas, shards or a CDN, yet |
That's the honest answer for today's SnipShare: two app servers, a load balancer, a cache and one database (plus object storage for the bodies, from §05). A read replica will come back in §09, but for failure, not for load.
Trade-offs: every choice costs something
Every box from §06 fixed a problem and added one. A cache made reads fast and made them possibly stale. A second app server added capacity and required a load balancer. This section covers the trade-offs that come up in almost every design, and how to talk about them.
Consistency vs availability vs latency
As soon as data lives in more than one place (replicas, caches, a second region), a write takes time to reach every copy. While it travels, the copies disagree. You have to pick what happens then:
- Consistency first: every reader sees the latest write, or gets an error. To guarantee that, writes must wait until enough copies have confirmed, which costs latency, and when copies can't reach each other, some requests must be refused, which costs availability.
- Availability first: every request gets a fast answer, from whatever copy is nearby, even if that copy is a little behind. That's Eventual consistency: the copies agree eventually, once the update arrives.
The CAP theorem is the formal version: when the network between copies breaks (a Network partition), a system must choose between staying correct and staying available. PACELC adds the everyday half: even when the network is fine, you trade latency for consistency. Both are taught properly in CAP & PACELC. Try the choice yourself:
Choose per piece of data, not per system
The most useful insight about this trade-off is that you don't make it once. Different data in the same product needs different answers:
| SnipShare data | If two copies briefly disagree… | Choose |
|---|---|---|
| A snippet's content | A reader in another region sees it a second later. Nobody is harmed. | Availability (and fast local reads) |
| View counters | The count is off by a few for a moment. | Availability |
A custom short name like /my-config (if we add it) | Two people in two regions both get the same name. One of them loses their link. | Consistency |
| Payment for a premium plan (if we add it) | Charged twice, or upgraded without paying. | Consistency (see the payments case study) |
SnipShare's requirement "others may see a new snippet up to a few seconds late" is exactly a decision to favour availability and latency for snippet content. Writing that sentence down in §02 is what lets you use replicas and caches freely now.
Cost vs complexity
Every box costs twice: once in money (machines, managed services, network) and once in complexity: more to deploy, monitor and understand, more ways to fail. The second cost is usually bigger, and it never shows up on the bill.
6 microservices, Kafka, Cassandra,
Kubernetes across 3 regions
→ peak load: 30 writes/s, 3,000 reads/s
→ a team of 4 spends most of its time
operating infrastructure2 app servers + load balancer,
managed Postgres + standby replica,
managed Redis, object storage
(+ a CDN once far-away users need it, §04)
→ same load, lots of headroom
→ the team ships featuresThe simple design isn't the "cheap" option; it's the one that matches the numbers. It also leaves a clear upgrade path: each next box has a number attached that tells you when to add it.
Build vs buy
Build vs buy is the choice between running a component yourself and using a product or managed service. For SnipShare, "buy" means a managed database, cache, load balancer and object storage, which matches the "small team" requirement from §02.
| Build (run it yourself) when… | Buy (use a managed service or product) when… |
|---|---|
| It's your core product, the thing that makes you different | It's plumbing every company needs: databases, queues, load balancers, auth |
| No product fits the requirements | A product fits well enough, and you can live with its limits |
| You have the people to run it and be on call for it | The team is small, or its time is better spent on features |
| The managed price at your scale is far above running it | Its price is acceptable for the people-time it saves |
The hidden cost of buying is lock-in: moving off a managed service later can be a large project. The hidden cost of building is the on-call rotation. Cloud Basics goes deeper on managed vs self-hosted.
How to state a trade-off
A trade-off said well has four parts: the choice, what it buys, what it costs, and why the cost is acceptable here. Compare:
- ❌ "We'll use eventual consistency because it scales better."
- ✅ "Snippet reads are served from the cache and the CDN, so a new snippet can take up to a few seconds to appear for other readers. That's allowed by the requirements, and it keeps the database free for writes. There are no accounts, so after saving, the author's browser gets a short-lived cookie; for a minute, requests with that cookie read from the database directly, so the author never sees their own snippet missing."
The second one can be checked: a reviewer can disagree with the requirement or with the "minute", instead of with a slogan. (That last trick gives the author Read-your-writes: Replication shows how to route those reads.)
Step 5: What if each box dies?
So far the design handles the load. But machines crash, disks fill up, networks drop packets and deploys go wrong. A design that's only correct when everything works is half a design. The method is mechanical, which is what makes it useful: point at each box in turn and ask "what if this dies?"
Three questions per box
- What do users see? Nothing? Slower pages? One feature missing? The whole site down?
- How does it recover? Automatically (a load balancer's health check skipping a dead server, or a Failover to a standby copy), or does a person have to do something?
- How would we know? What metric or alert tells us before users do?
Two answers are red flags. "The whole site is down" for a box that has no spare means it's a single point of failure. And "we'd find out from users" means you're missing monitoring.
Try it
This is SnipShare's design with every block. Click (or tap) a box to kill it, and look at what happens to each of the three endpoints. Then kill two at once: the cache first, then the cache and the primary database.
Patterns that came up
Redundancy: run one more than you need. The app tier survives a dead server because there are two behind a Load balancer that runs health checks. Each runs at about 30% at peak (§07); if one dies, the other carries everything at about 60%, right at our target. The rule of thumb is N+1 redundancy: enough capacity for the peak, plus one spare, so a failure (or a deploy) during the peak doesn't overload the rest. That's also why §07's "one server at 60%" wasn't good enough.
Failover: a standby that can take over. The database primary can't simply be duplicated, because two primaries accepting writes would disagree. Instead a Replica follows it and gets promoted when it dies. That's the real reason SnipShare has a replica: for failure, not for load. One catch: replication is usually an asynchronous write, so the primary says "saved" before the replica has the data, and the last few writes before a crash can be missing after promotion. For "never lose a saved snippet", the primary must wait for the replica to confirm each write. How promotion works and what can be lost is in Replication.
Know what your CDN is hiding. If the CDN fails completely, every read it used to answer lands on your own servers, which CDN people call the origin. Plan for the origin to survive that, or for a second CDN.
Watch for load-bearing caches. Losing the cache didn't break anything by itself, but it multiplied the database's load by 10. If the database can't survive that, the cache has quietly become something the system can't live without. Either size the database for a cold cache, or protect it, for example with request coalescing: when many requests miss on the same key at once, only one goes to the database and the rest wait for its answer. Caching explains it.
Graceful degradation: let optional features fail alone. Search being down shouldn't stop people reading snippets. Keep non-essential features off the critical path: call them with short timeouts, and show "unavailable right now" instead of an error page. Graceful degradation is a design decision you make in advance, not something that happens by luck.
Decouple slow work with a queue. Indexing a snippet for search happens in a background worker fed by a queue. When the worker dies, the queue holds the backlog and nothing else breaks. The cost: you must decide what happens if the queue itself is down, and handle jobs that run twice (Idempotency covers that). A common answer is the outbox pattern: save the snippet and an "index this" row in the same database transaction, and let a small relay move rows from that table to the queue. Saving never depends on the queue being up, and no job is lost (Sagas: outbox and inbox).
Correlated failures
The toggles above kill one box at a time, but real outages often take several at once: a whole data center (an Availability zone) loses power, a bad config is deployed everywhere, a shared dependency like DNS breaks. Two replicas on the same rack aren't really two. That's why production designs spread copies across zones, and roll out changes gradually. Reliability & SRE covers planning for losing a whole zone.
SLOs as requirements, and capacity with headroom
§02 wrote "read p99 < 200 ms" and "99.9% available" as requirements. At a staff level, those sentences have to become things you can measure, alert on, and use to decide what to build next. This section connects the design to the operational topics: Observability and Reliability & SRE.
From requirement to SLO
An SLI is a measured number that reflects what users experience. An SLO is the target for it over a time window. The gap between 100% and the target is the Error budget: how much failure you're allowed before reliability work takes priority over features.
| Requirement (§02) | SLI (what we measure) | SLO (the target, 30-day window) |
|---|---|---|
| Opening a snippet feels fast | Share of GET /snippets/{id} answered in under 200 ms | 99% |
| The site is up | Share of requests answered without a server error | 99.9% |
| Saved snippets are never lost | Share of saved snippets readable afterwards (checked by a background prober) | 100%, with any miss treated as an incident |
| New snippets appear in search | Share of snippets searchable within 5 minutes of saving | 99% |
Notice the last one: it measures the queue + indexer path from §09. If you only measure what the app server does, an indexer that has been dead for a day looks perfectly healthy. An SLO per user-visible promise catches failures in the background parts too.
What the nines mean
"Nines" are shorthand for availability targets. Over a 30-day month (43,200 minutes), each one allows this much failure:
| Target | Allowed failure per 30 days | Roughly |
|---|---|---|
| 99% | 432 minutes | 7 hours |
| 99.9% | 43.2 minutes | under an hour |
| 99.99% | 4.3 minutes | a single slow failover |
| 99.999% | 26 seconds | no human can even react in time |
Each nine allows 10× less downtime and typically costs far more than 10× the effort: automatic failover everywhere, multiple zones or regions, very careful deploys. That's why "how many nines?" is a requirements question with a cost attached, not a technical detail.
Availability multiplies along the request path
If a request needs three boxes in a row and each is available 99.95% of the time or better, the request succeeds only when all three are up, so you multiply. A worked example with round, made-up numbers:
- Load balancer 99.99% × app tier 99.95% × database 99.95%
- = 0.9999 × 0.9995 × 0.9995
- ≈ 0.9989, which is 99.89%: below a 99.9% target, even though every part beats it.
Every dependency on the critical path lowers the ceiling. That's another argument for the simple design: fewer boxes in a row means fewer numbers to multiply. It also explains graceful degradation from §09: search isn't on the read path, so its downtime doesn't count against reading.
Redundancy works the other way. Two copies that fail independently, each up 99% of the time, are both down only 0.01 × 0.01 = 0.01% of the time: 99.99% together. The catch is the word "independently": two copies in the same zone, running the same buggy config, fail together. That's why correlated failure (§09) is the thing to design against.
Capacity planning with headroom
Capacity planning asks: will we have enough of everything for the busiest moment coming up, even if something breaks? Here's the app tier for SnipShare, step by step. Unlike the simulator, these inputs should be measured, with a load test, not guessed:
Why 15 here when §07 ended with 2? §07 used the simulator's made-up 5,000 QPS per server and only today's load. Here a real load test says 1,000 QPS per server, we keep 40% spare, plan for next year, and survive losing a zone. Same method, honest inputs, very different answer: that's why capacity planning uses measurements, not guesses.
- Today's peak: about 3,500 QPS (the exact figure from the §03 script).
- Growth: the product team expects +50% over the next year → forecast peak 3,500 × 1.5 = 5,250 QPS.
- Per-server capacity: a load test shows one app server handles about 1,000 QPS before p99 latency breaks the SLO.
- Target utilization: plan to run at most 60% busy at peak, which leaves Headroom for spikes and slow moments → each server counts as 600 QPS.
- Survive losing a zone: servers are spread across 3 zones; if one dies, the other two (2/3 of the fleet) must carry the whole peak → 5,250 ÷ (600 × 2/3) ≈ 13.1 → round up to 5 per zone = 15 servers.
package main
import (
"fmt"
"math"
)
// serversNeeded: enough servers that, even with one zone down,
// the rest run at or below targetUtil at the forecast peak.
func serversNeeded(peakQPS, perServer, targetUtil float64, zones int) int {
healthy := float64(zones-1) / float64(zones) // share left after losing one zone
n := peakQPS / (perServer * targetUtil * healthy)
perZone := math.Ceil(n / float64(zones)) // same count in every zone
return int(perZone) * zones
}
func main() {
forecast := 3500 * 1.5 // today's peak × expected growth over the next year
fmt.Println("forecast peak:", forecast, "QPS")
fmt.Println("servers:", serversNeeded(forecast, 1000, 0.6, 3))
}forecast peak: 5250 QPS
servers: 15Fifteen servers, when today's peak could be carried by 4 servers running at nearly 90% busy, can look wasteful. It isn't: it's the price of "a whole zone can die on our busiest day of the year and users don't notice". The same reasoning applies to everything else with a limit: database connections, cache memory, disk, queue throughput, and cloud quotas. Reliability & SRE covers forecasting and the limits that aren't servers.
Step 6: Write it down in a design doc
At work, the output of the method isn't a whiteboard photo: it's a design doc (some teams call it an RFC, "request for comments", because it's circulated for others to comment on). It records the requirements, the numbers, the design and, most importantly, the reasoning, so reviewers can disagree with a specific assumption instead of with the whole thing. Six months later it also answers "why on earth did we build it this way?".
A one-page template
Every section maps to a step of the method. Short is better: a reviewer should get the main idea in five minutes.
# SnipShare v1: paste a snippet, share a link
Author · Reviewers · Status (draft / in review / approved) · Date
## Context and goals ← why now, what problem, for whom
## Requirements ← §02: functional, non-functional (as SLOs), out of scope
## Estimates ← §03: QPS avg/peak, storage/year, bandwidth, read:write
## API and data model ← §05: endpoints with load, schema, access patterns
## Proposed design ← §06–07: diagram, request path, why each box exists
## Alternatives considered ← what else you could do, and why not
## Failure modes ← §09: what if each box dies; what we monitor
## Rollout and migration ← how it gets to production safely; how to undo it
## Risks and open questions ← what you don't know yet, and how you'll find outSnipShare v1, filled in
Here's the whole method's output for SnipShare in one place: one or two lines per heading, pulling together §02–§10.
# SnipShare v1: paste a snippet, share a link
Context Developers want to share code without accounts. Goal: launch v1 for ~10M daily users.
Reqs Paste ≤ 1 MB → short link; read by link; expiry; keyword search on public snippets.
Read p99 < 200 ms, write p99 < 1 s, 99.9% available, saved snippets never lost,
others may see new snippets a few s late. Out of scope: accounts, comments, edits.
Estimates ≈ 3,000 read QPS + 30 write QPS at peak (100:1, read-heavy); ≈ 4 TB bodies/yr
(≈ 60 TB over 5 yr with 3 copies); ≈ 36 GB metadata/yr; ≈ 30 MB/s out at peak.
API/data POST /snippets, GET /snippets/{id} (97% of traffic), GET /search.
Postgres row per snippet (~100 B) + body in object storage under body_key.
Design LB → app servers → Redis cache (90% hits) → Postgres primary + standby replica;
bodies in object storage; on save, an outbox row → queue → indexer → search index.
Every box under 60% at peak. App tier: 2 servers on paper (§07); sized from a
load test for launch: ≈ 15 across 3 zones for next year's peak (§10).
Alternatives Bodies in Postgres (backups too big); DynamoDB (no need to switch);
replicas instead of a cache (a cache is cheaper for unchanging snippets).
Failures App server or a whole zone dies → the rest stay under target (N+1). Primary dies → promote replica
(writes wait for the replica, so nothing saved is lost). Cache dies → DB must
survive 10× reads. Search/indexer down → search degraded, core unaffected.
SLOs 99% of reads < 200 ms; 99.9% without server errors; 99% searchable within 5 min.
Rollout Launch behind a feature flag; load test to 2× forecast peak first.
Risks Real per-server capacity unknown until load tested (§10 sizing depends on it);
short-id collisions across servers (see the URL shortener case study).The section reviewers read first
Experienced reviewers often jump straight to Alternatives considered. A design with no alternatives suggests the author picked the first idea that came to mind. Two or three real options, each with one line on why not, show the trade-offs were weighed:
| Option | Why not (for SnipShare v1) |
|---|---|
| Store snippet bodies in Postgres too | Simplest, but ≈ 4 TB/year would make backups and replicas slow and expensive. Revisit if snippets stay small. |
| A key-value store (e.g. DynamoDB) instead of Postgres | Fits the access pattern well, but the team already runs Postgres; nothing in the numbers requires the switch. |
| No cache; add read replicas instead | Works, but snippets never change and the same ones are read repeatedly: a cache is cheaper per read. |
"Do nothing" or "the simplest possible version" belongs in the list too. Sometimes it wins.
Running a design review
- Send the doc ahead. Reviewers comment in writing first; the meeting is for the disagreements that remain.
- Ask reviewers for the questions that hurt. "Which number would make this design wrong?" "What happens on the busiest day if the primary dies?" "What's the plan to undo this?"
- Record the decisions in the doc, including the ones you rejected. That's the document's long-term value.
The second-system trap
The second-system effect is the urge, when replacing a system that worked, to put every improvement you ever wanted into the replacement at once. Fred Brooks described it in The Mythical Man-Month (1975), and it still happens: the "v2 rewrite" that is general enough for every future need, takes three times as long as planned, and ships with more problems than v1 had.
Warning signs in a design doc:
- Requirements that no user asked for ("pluggable storage backends", "support for any future content type").
- Capacity planned for 100× today's numbers with no forecast behind it.
- More new components than problems listed in "Context and goals".
- No step-by-step migration: the new system replaces the old one in a single big switch.
The fix is the same as in §07: solve the problems the numbers and requirements show, and leave a clear path for the rest. Migrate in small steps, keeping the old and new systems side by side, so each step can be checked and undone. (The strangler fig pattern is the classic way to do this.)
Common mistakes
Choosing tools before knowing the problem
"We'll use Kafka, Cassandra and Kubernetes" in the first minute. Tools are answers; without requirements and numbers there's no question yet. Fix: requirements and estimates first (§02–03). Name a product only when a box needs one, and say why that product.
Designing for the average
1 million requests a day "is only 12 QPS", until the morning peak brings 5× that, and a launch day brings 50×. Fix: always multiply by a peak factor, and ask whether the product has bursty events (§03).
Boxes without numbers
A diagram with a cache, three replicas and a queue, and no idea how much load any of them carries. Fix: write the load next to each box (§05, §07). If you can't say which number justifies a box, remove it.
Fixing the wrong bottleneck
Adding read replicas to a database that is overloaded by writes, or adding app servers when the database is the limit. Fix: find the box with the highest utilization first, and check why it's busy (reads or writes? repeated or varied?) before choosing the fix (§07).
Treating precision as accuracy
Spending ten minutes computing "11,574.07 QPS" from inputs that were guesses to begin with. Fix: round to one significant digit and powers of ten. Being within 10× is the goal; spend the time on the design instead (§03).
Only designing the happy path
The design works perfectly as long as nothing breaks. Fix: walk every box with "what if it dies?" (§09). Look especially for single points of failure and for caches the database can't live without.
One consistency answer for everything
Either "everything must be strongly consistent" (slow and fragile) or "eventual consistency everywhere" (until two people get the same username). Fix: decide per piece of data, and write the decision down (§08).
Forgetting the network
Ten calls in a row to other services, each a few milliseconds, and a page that somehow takes a second. Or servers on one continent serving users on another. Fix: count round trips on the request path and spend the latency budget deliberately (§04).
Building version 3 first
Planning for 100× growth with no forecast behind it, or rewriting a working system with every wished-for feature at once. Fix: build for the numbers you have, plus a clear plan for the next step up (§07, §11).
Check yourself
Work each answer out on paper before you click: a quick estimate is the skill being tested. Q1, Q2 and Q6 practise §03; Q3 is §06–07; Q4 is §04; Q5 is §09; Q7 is §02.
A service gets 50 million requests a day, spread evenly. Roughly what's the average QPS?
Average traffic is 2,000 QPS with a peak factor of 3. One app server can take 1,000 QPS while staying under the 60% target (that already includes the headroom). How many servers should you run?
Your database is at 100% because of writes (read:write is about 1:1). You add three read replicas. What happens?
A page must load within 200 ms. The server makes 3 database queries in the same data center (≈ 1 ms each), then calls a service on another continent twice, one call after the other (≈ 120 ms each). Does it fit?
A cache in front of the database has a 90% hit rate, and the database currently serves 500 reads a second. The cache crashes and restarts empty. What does the database see?
Users upload 2 million photos a day, about 500 KB each. Roughly how much new storage per year, before copies?
You have 45 minutes to design a ticket-booking site. Which is the best first move?
Cheat sheet
| Number | Approximate value |
|---|---|
| Seconds in a day | 86,400 ≈ 105 |
| 1 million / day | ≈ 10 QPS (exactly 11.6) |
| 100 million / day | ≈ 1,000 QPS |
| 1 KB / MB / GB / TB / PB | 103 / 106 / 109 / 1012 / 1015 bytes |
| RAM read | ≈ 100 ns |
| SSD random read | ≈ 20–100 µs |
| Round trip, same data center | ≈ 0.5 ms |
| Round trip, across a continent or ocean | ≈ 80–150 ms |
| 99.9% / 99.99% availability | ≈ 43 min / ≈ 4.3 min of failure per 30 days |
| Bytes/s → bits/s | × 8 |
| Symptom | First reach for |
|---|---|
| One app server full | Load balancer + more app servers |
| Database busy with repeated reads | Cache |
| Database busy with varied reads | Read replicas |
| Database busy with writes, or data too big | Shards (replicas won't help) |
| Far-away users, popular public content | CDN |
| Slow work the user needn't wait for | Queue + workers |
| Big files | Object storage |
| Keyword search | Search index |
| Strong consistency needed | Only for the data that really needs it |