System Design NotesRohit’s interview study guide
Chapter 01

The System Design Interview

What the round measures, a framework you can run every time, requirements that drive the design, estimates calculated in code, the latency and availability numbers to defend them, and how to draw and talk through it all.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What the interviewer is looking for

Added

A system design interview is not a quiz with one right answer. It is a simulation of the most senior part of an engineer's job: someone hands you a vague goal ("design a photo-sharing app"), and you turn it into a design that works, at a scale you have agreed, with costs you can name. The interviewer is not waiting for you to say "Kafka". They are watching how you get from the vague goal to a defensible design.

A good mental model: the interviewer is writing a report while you talk. Every few minutes they jot down evidence: "asked about read/write ratio before choosing a database", "estimated 17 K QPS and used it to size the cache", "noticed the single point of failure on their own". At the end they turn those notes into a recommendation. Anything you think but don't say is invisible to that report. Anything you say without a reason ("I'd use Cassandra") is weak evidence; the same choice with a reason and a trade-off ("writes dominate and we only read by user id, so a wide-column store partitioned by user fits; we give up ad-hoc queries") is strong evidence.

So the round rewards the habits of a lead engineer in a design review: pin down the problem, put numbers on it, build something that works end to end, then go deep where it is hard and say what you are trading away.

The signals

Every company writes its own rubric and keeps it private, but the themes are the same everywhere. Hello Interview, a popular prep site, groups them into problem navigation, solution design, technical excellence, and communication and collaboration (checked October 2026). In plain terms:

SignalStrong evidenceWeak evidence
Scoping (problem navigation)Asks questions whose answers change the design; writes down 3–5 core features and the numbers to design for; says what is out of scopeStarts drawing boxes in minute two; asks 15 questions that don't matter; never states the scale
A working designEvery agreed feature has a path through the boxes; data has a home; the design would actually runLots of components, but one requirement has no path (uploads go nowhere, the feed is never built)
Trade-offs and depthNames at least two options for a hard part, picks one with a reason tied to the requirements, knows what breaks firstBuzzwords without reasons; "it scales horizontally" with no mechanism; can't answer "what happens when this node dies?"
CommunicationThinks aloud, checks in at phase boundaries, adapts when given a hint, keeps the board readableLong silences while drawing; defends a mistake after being told; the interviewer has to drag the design out

How the bar changes with level

The same prompt is used at several levels; what changes is how much you drive and how deep you go. This summary follows Hello Interview's write-up of expectations per level (Evan King, a former Meta staff engineer; checked October 2026):

LevelBreadthDepthWho drives
Mid-levelBroad, basic knowledge; expect "what does an API gateway actually do?" for every box you drawTextbook understanding is enoughYou lead requirements, API and the high-level design; the interviewer steers the later questions
SeniorFundamentals are assumed and not probed unless you show a gapGo deep in about two places, ideally from real experience (why this partition key, what the failure looks like)You drive, spot what makes this system hard, and point out your own design's weak spots
Staff and aboveAssumed completeDeep in several areas, ideally with insight the interviewer may not haveYou run nearly the whole session as a peer

For Rohit, interviewing as an experienced full-stack engineer, the senior row is the target: finish a working design early enough to spend real time on two deep dives, and raise the weaknesses before the interviewer does.

How it is scored

At most large companies each interviewer writes structured feedback (evidence per signal) and a recommendation on a scale such as strong no hire → no hire → hire → strong hire. A debrief or hiring committee reads all the rounds together, and the design round often weighs heavily in the level you are offered, not only in yes or no. Two practical consequences follow:

  • Evidence beats impressions. "Candidate said the cache could serve stale feed items for up to 30 seconds and that this is acceptable because likes are eventually consistent" can be written down and defended in a debrief. "Seemed smart" cannot.
  • Running out of time is the most common failure. Hello Interview's delivery guide calls failing to deliver a working system the most common reason mid-level candidates fail. A complete but plain design beats a brilliant half of one.
Two candidates, same design

Both candidates end with the same boxes: load balancer, stateless API servers, Redis cache, MySQL with read replicas, S3 and a CDN. Candidate A drew them in the first eight minutes and spent the rest explaining what each box does. Candidate B spent ten minutes on requirements and estimates, then drew the same boxes, saying for each why it is there and what it costs. Who scores better, and on which signals?

Show answer

Candidate B, on every signal except perhaps speed.

What’s happening
  1. Scoping: A never established the scale or the read/write ratio, so there is no evidence A knows why this design fits. B's ten minutes produced numbers (say 17 K feed QPS, 100:1 reads to writes) that the design visibly answers.
  2. A working design: identical boxes, so on paper this is a tie. But B can show each feature's path through the boxes because B wrote the features down first.
  3. Trade-offs: A's explanations are definitions ("Redis is an in-memory store"). B's are decisions ("reads outnumber writes 100 to 1, so a cache in front of MySQL takes most of the load; we accept up to 30 s of staleness on like counts").
  4. Communication: B checked in at phase boundaries and tied the design to agreed requirements; A lectured.
  5. The lesson: the same diagram can be a weak or a strong interview. The reasons attached to the boxes are what gets scored.

#A repeatable framework

Added

Under pressure, people skip steps: they forget to ask about scale, never write an API, or spend 25 minutes on one component. A framework fixes that the way a pilot's checklist does. It is not there because pilots are forgetful; it's there because a fixed order frees your attention for the hard part. You never have to decide what to do next, only how well to do it.

The framework on this site has seven phases, and every case study later follows it: requirements → estimates → API → data model → high-level design → deep dives → wrap-up. The mental model is a relay: each phase hands an artefact on the board to the next one. The requirements decide which numbers you estimate; the numbers decide which components you need; the API names what flows between them; the data model decides where it is stored; the high-level design connects all of it; the deep dives fix the parts that the numbers say will break.

The time budget matters as much as the order. In a 45-minute round, roughly 5 minutes go to introductions and 5 to your questions at the end, which leaves about 35 minutes of design. The picture shows one budget that works.

A 45-minute round, minute by minute 5 5 2 3 2 10 10 3 5 Intro Estimates Data model Deep dives Your questions Requirements API High-level design Wrap-up 0 45 min scope it: 12 min build it, then go deep: 20 min close: 3
Numbers in the bar are minutes. Blue phases decide what to build; the accent and violet phases build it and go deep.

The budget is easy to get wrong at the whiteboard, so here it is as code: give each phase a percentage of the design time and let the program turn it into whole minutes and clock times, for a 45- and a 60-minute round.

interview-plan.mjs
// A time budget for the design interview: each phase gets a percentage of the minutes left for design.
const phases = [
  { name: "Requirements", pct: 14, ends: "agreed features + the numbers to design for" },
  { name: "Estimates", pct: 6, ends: "QPS, storage, bandwidth on the board" },
  { name: "API", pct: 9, ends: "3-5 endpoints or events" },
  { name: "Data model", pct: 6, ends: "entities, keys, which store" },
  { name: "High-level design", pct: 29, ends: "every requirement served by a box" },
  { name: "Deep dives", pct: 29, ends: "2 hard parts solved, alternatives weighed" },
  { name: "Wrap-up", pct: 7, ends: "bottlenecks, failures, what's next" },
];

// Whole minutes that add up exactly: floor every phase, then hand the leftover
// minutes to the largest remainders (ties go to the bigger phase).
function plan(total, intro = 5, questions = 5) {
  const design = total - intro - questions;
  const rows = phases.map((p, i) => ({ ...p, i, exact: (p.pct * design) / 100, minutes: Math.floor((p.pct * design) / 100) }));
  let left = design - rows.reduce((s, r) => s + r.minutes, 0);
  const byRemainder = [...rows].sort((a, b) => (b.exact - b.minutes) - (a.exact - a.minutes) || b.pct - a.pct);
  for (const r of byRemainder) if (left-- > 0) r.minutes++;
  let clock = intro;
  return { design, rows: rows.map((r) => ({ ...r, start: (clock += r.minutes) - r.minutes })) };
}

const at = (m) => `${String(m).padStart(2, "0")}:00`;
for (const total of [45, 60]) {
  const { design, rows } = plan(total);
  console.log(`${total} min: 5 intro + ${design} design + 5 for your questions`);
  for (const r of rows) console.log(`  ${at(r.start)}  ${r.name.padEnd(18)}${String(r.minutes).padStart(3)} min  → ${r.ends}`);
  console.log(`  ${at(total - 5)}  Your questions for them\n`);
}
Result
45 min: 5 intro + 35 design + 5 for your questions
  05:00  Requirements        5 min  → agreed features + the numbers to design for
  10:00  Estimates           2 min  → QPS, storage, bandwidth on the board
  12:00  API                 3 min  → 3-5 endpoints or events
  15:00  Data model          2 min  → entities, keys, which store
  17:00  High-level design  10 min  → every requirement served by a box
  27:00  Deep dives         10 min  → 2 hard parts solved, alternatives weighed
  37:00  Wrap-up             3 min  → bottlenecks, failures, what's next
  40:00  Your questions for them

60 min: 5 intro + 50 design + 5 for your questions
  05:00  Requirements        7 min  → agreed features + the numbers to design for
  12:00  Estimates           3 min  → QPS, storage, bandwidth on the board
  15:00  API                 4 min  → 3-5 endpoints or events
  19:00  Data model          3 min  → entities, keys, which store
  22:00  High-level design  15 min  → every requirement served by a box
  37:00  Deep dives         15 min  → 2 hard parts solved, alternatives weighed
  52:00  Wrap-up             3 min  → bottlenecks, failures, what's next
  55:00  Your questions for them
What’s happening
  1. plan(45) first removes the fixed parts: 5 minutes of introductions and 5 for your questions, so design is 35. For 60 minutes it is 50.
  2. Each phase gets pct × design / 100 minutes exactly: Requirements 14 × 35 / 100 = 4.9, High-level design 29 × 35 / 100 = 10.15. Fractions of minutes are useless at a whiteboard, so Math.floor rounds every phase down first (4, 2, 3, 2, 10, 10, 2 = 33 minutes).
  3. The 2 leftover minutes go to the phases that lost the most to rounding: Requirements (lost 0.9) and Wrap-up (lost 0.45). That gives 5, 2, 3, 2, 10, 10, 3, which adds up to exactly 35. This is the largest-remainder method; ties go to the bigger phase, which is how the 60-minute plan gives its two spare minutes to the high-level design and the deep dives.
  4. The percentages are integers on purpose: with 0.29 × 50 JavaScript computes 14.499999999999998, not 14.5, and the tie-break would silently pick the wrong phases.
  5. start is a running clock, so the printout reads like a schedule: by 17:00 you should be drawing the high-level design, and by 27:00 you should be in deep dives. Those two checkpoints are the ones to glance at the clock for.
  6. A longer round mostly buys depth: going from 45 to 60 minutes adds 15 minutes of design, and 10 of them land in the high-level design and the deep dives.
AdvancedWhat each phase leaves on the board

What each phase produces

PhaseWhat you doOn the board when it endsTrap
RequirementsAsk questions that change the design; list core features; agree on scale, latency, availability, consistency3–5 functional requirements, 4–6 non-functional ones with numbers, an out-of-scope listAsking about everything; no numbers
EstimatesTurn the scale into QPS, storage and bandwidthAverage and peak QPS for reads and writes, storage per year, egressPrecise maths nobody needs; estimates that never get used
APIName the main operations and their inputs and outputs3–5 endpoints (or events, for async systems)Designing every field; forgetting pagination or idempotency
Data modelName the entities, their keys and how they are readTables or documents with primary keys and the main access pattern each servesChoosing a database before knowing the access pattern
High-level designConnect clients, services and stores so every requirement has a pathA diagram you can walk a request throughDrawing components you can't justify
Deep divesPick the 2 hardest parts (often the ones the numbers flag) and solve them properlyAlternatives with trade-offs and a choiceGoing deep before the design is complete
Wrap-upBottlenecks, failure modes, monitoring, what you'd do with more timeA short spoken summaryRunning out of time before getting here
AdvancedAdapting the framework to the interviewer

Adapting the framework

The framework is an order of work, not a script to recite:

  • Estimates can shrink or move. If the numbers won't change the design (an internal tool for 200 users), say so and skip to the API: "At this scale one server and one database are plenty; I'll come back to numbers if we need them." Hello Interview's delivery guide also recommends estimating only when it will influence the design. Many interviewers do expect a few numbers, though, so offer: "Would you like me to estimate the load?"
  • Async systems get events, not endpoints. For a notification pipeline or a job scheduler, the "API" phase is the shape of the messages and who produces and consumes them.
  • Some interviewers drive. If they jump in with "let's talk about the feed generation", follow them. You can say "Sure. I'll note the other parts quickly so we don't lose them" and sketch the rest in one line.
  • Deep-dive topics come from the numbers and the interviewer. The phases before it usually reveal which parts are hard ("174 K image reads per second" says the image path is one). Offer two and let the interviewer choose: "I think the hard parts are feed generation for users with millions of followers, and the image pipeline. Which would you like first?"
AdvancedGotcha: fifteen minutes of requirements

#Functional and non-functional requirements

Added

Requirements come in two kinds. Functional requirements are what the system does: the verbs users perform ("post a photo", "see a feed of people I follow", "like a post"). Non-functional requirements are how well it must do them: how many users, how fast, how often it may be down, how fresh the data must be, whether anything may ever be lost.

A useful mental model is a restaurant. The functional requirements are the menu: which dishes exist. The non-functional requirements are the service promises: 400 covers a night, food within 15 minutes, open every day of the year, never serve something unsafe. A menu of three dishes is easy to cook for four friends. The same menu for 400 covers an hour needs a different kitchen. The non-functional requirements are what make a design problem hard. A photo-sharing app for 100 users is a weekend project; for 50 million daily users it is this whole site.

So the requirements phase has two jobs: cut the functional list down to the few features that matter (everything else is explicitly out of scope), and turn every adjective into a number. "Fast" becomes "feed loads in under 300 ms at p99"; "highly available" becomes "99.9% for uploads, 99.99% for viewing"; "scalable" becomes "50 M daily active users, 5 M uploads a day".

Questions worth asking

Ask the questions whose answers would change the design. A good test: if you can't name what you'd do differently for each possible answer, don't ask it.

AreaQuestionWhy it changes the design
Users and scaleHow many daily active users? How many actions per user per day?Decides one server vs a fleet, one database vs shards
Read/write mixDo reads outnumber writes, and by how much?Read-heavy systems lean on caches, replicas and CDNs; write-heavy ones on partitioning and append-only storage
Data sizeHow big is a typical item? Do we keep everything forever?Decides database vs object storage, and total storage cost
LatencyWhich operations must feel instant, and which can take seconds?Decides what must be precomputed or cached, and what can be async
AvailabilityWhat happens if it is down for an hour? Do some features matter more?Decides redundancy, multi-zone or multi-region, and what can degrade gracefully
ConsistencyAfter a write, must every reader see it immediately? Or is a few seconds stale fine?Strong consistency costs latency and availability; eventual consistency allows caches and async replication
DurabilityMay we ever lose data? Which data?Decides replication, backups, and where the source of truth lives
GeographyUsers in one country or worldwide? Data residency rules?Decides CDNs, regions and how data is partitioned

Write the answers on the board as you get them. If the interviewer says "you decide", decide out loud with a reason and a number, and move on.

Here is what the board can look like after five minutes on the photo-sharing app used throughout this chapter:

Board notes: photo-sharing app
FUNCTIONAL (in scope)
  1. Upload a photo with a caption
  2. Follow other users
  3. Home feed: recent photos from people I follow, newest first
  4. Like a photo; see like counts
OUT OF SCOPE: comments, stories, search, ads, direct messages

NON-FUNCTIONAL
  Scale        50 M DAU; 1 in 10 users posts a photo a day (5 M/day);
               ~10 feed loads and ~100 photo views per user per day
  Read/write   reads >> writes (per upload: ~100 feed loads, ~1,000 photo views)
  Latency      feed API p99 < 300 ms; an upload may take a few seconds
  Availability viewing 99.99%; uploading 99.9% (can degrade first)
  Consistency  feed may be ~seconds stale; my own post shows to me at once
  Durability   never lose an uploaded photo
  Size         originals ~2 MB; keep forever
What’s happening
  1. The functional list is four verbs, each a real user action. That is enough to drive a complete design; a fifth or sixth feature would add boxes without adding new problems.
  2. The out-of-scope line is as important as the in-scope list. It protects your time ("we agreed comments are out") and shows the interviewer you are making scoping decisions, not forgetting things.
  3. Every non-functional line has a number or a precise rule. "50 M DAU" and "1 in 10 users posts a day" feed straight into the estimates in the next topic; "p99 < 300 ms" is what you'll check the feed design against.
  4. Availability is split by feature: viewing gets four nines, uploading three. That one line already tells you the read path must survive things the write path doesn't, for example by serving cached feeds when the database is struggling.
  5. Consistency is also split: the feed can be seconds stale (eventual consistency, so caches and async fan-out are allowed), but read-your-own-writes for the poster. That is a classic requirement interviewers love to probe later, and having it written down shows you thought of it.
  6. "Never lose an uploaded photo" is a durability requirement, separate from availability: the service can be down for a minute and still never lose data. It points at replicated object storage as the source of truth for image bytes.
AdvancedRequirement trade-offs and a chat example

How each non-functional requirement pushes the design

RequirementPushes the design towardsWhat it costs
Very read-heavy (100:1)Caches, read replicas, CDNs, precomputed views (a feed stored ready to read)Staleness; cache invalidation work
Very write-heavyPartitioning (sharding) by a well-spread key, append-only logs, batching, queuesHarder queries across partitions; rebalancing
Low latency (p99 in tens of ms)Data in memory, close to the user, precomputed; fewer hops; parallel callsMemory cost; more copies to keep in sync
High availability (99.99%+)Redundancy in every layer, across zones or regions; graceful degradationMoney; usually weaker consistency (see the CAP discussion later on the site)
Strong consistencyA single leader per piece of data, synchronous replication, consensusHigher write latency; less availability during partitions
High durabilityReplication across machines and zones, backups, an immutable source of truthStorage cost; write latency for synchronous copies
Global usersCDNs, multiple regions, data partitioned by geographyCross-region consistency is slow (round trips of 70–260 ms, see Latency numbers to know)

Requirements interact. "Four nines and strongly consistent everywhere" is expensive and, during a network partition, contradictory. The skill is to choose per feature: like counts can be eventually consistent, but a payment balance cannot.

The same format works for any prompt. Here is a chat system, where the interesting requirements are completely different:

Board notes: chat system
FUNCTIONAL
  1. 1:1 and group chats (groups up to 500 members)
  2. Send and receive text messages in real time
  3. Delivery and read receipts
  4. Message history on all my devices
OUT OF SCOPE: voice/video calls, media, end-to-end encryption details

NON-FUNCTIONAL
  Scale        100 M DAU, ~40 messages sent per user per day
  Latency      message delivered to an online recipient in < 500 ms
  Ordering     messages appear in the same order for everyone in a chat
  Delivery     no message lost; duplicates hidden from the user
  Availability 99.99% for sending; history can lag briefly
  Retention    keep history forever
What’s happening
  1. The verbs are different, but the shape is the same: a short functional list, an out-of-scope line, and numbers for every quality.
  2. Two new requirement types appear, ordering and delivery guarantees, and they are the heart of this problem. "Same order for everyone in a chat" means you need a sequence per conversation, not per server clock.
  3. "No message lost; duplicates hidden" is the standard pairing of at-least-once delivery with idempotent handling (a message id the client uses to drop repeats), stated as a requirement before any technology is named.
  4. The latency requirement is about push, not page loads: under 500 ms to an online recipient points at persistent connections (WebSockets) rather than polling.
  5. Comparing the two boards shows why you never reuse a design blindly: a feed is read-heavy and tolerant of staleness; chat is write-heavy, ordering-sensitive and real-time.
AdvancedGotcha: adjectives are not requirements
Functional or non-functional?

Sort these: (a) users can edit a caption after posting, (b) the feed loads in under 300 ms, (c) a deleted photo disappears from all feeds within one minute, (d) users can block another user, (e) the service survives the loss of one data centre.

Show answer

Functional: (a) and (d). Non-functional: (b), (c) and (e).

What’s happening
  1. (a) and (d) are things a user can do: new verbs on the menu. Each adds an API operation and probably a table.
  2. (b) is a latency requirement on an existing feature: it doesn't add a feature, it constrains how the feed is built.
  3. (c) is a consistency/freshness requirement: deleting is a feature, but "within one minute everywhere" is the quality bar, and it decides how deletes propagate to caches and precomputed feeds.
  4. (e) is an availability requirement: it demands redundancy across data centres.
  5. The rule: if it adds a verb, it's functional; if it adds a number or a guarantee about an existing verb, it's non-functional.

#Back-of-the-envelope estimation

Added

Estimation turns the agreed scale into the few numbers that decide the architecture: requests per second, storage, and bandwidth. The goal is not accuracy; it is the order of magnitude. Is it 50 requests a second (one server) or 50,000 (a fleet and a cache)? Is the data 50 GB (fits in memory) or 50 PB (object storage, nothing else)? Being within a factor of two or three is fine; being off by a factor of 1,000 leads to the wrong design.

The mental model is a carpenter's tape measure, not a laser survey. You measure quickly, round sensibly, and you only measure what you are about to cut: every estimate should be used to make a design decision a few minutes later.

The chain is always the same:

  • Requests per day = daily active users × actions per user per day.
  • Average QPS = requests per day ÷ 86,400 seconds.
  • Peak QPS = average × a peak factor. Traffic is not flat: evenings, weekdays and events concentrate it. A factor of 2–3 is a common rule of thumb for consumer apps; ask, or state the one you use.
  • Storage = new items per day × bytes per item × copies kept, then × 365 × years.
  • Bandwidth = QPS × bytes per request (in) or per response (out). Convert bytes to bits (× 8) for network numbers, since links are sold in bits per second.

Rather than redo that chain by hand for every case study, here it is as a small reusable estimator. Each action declares how often a user does it and what bytes it moves or stores; the estimator does the arithmetic and prints numbers with sensible units. The worked example is the photo-sharing app from the requirements topic.

estimate.mjs
// A reusable back-of-the-envelope estimator. Decimal units throughout (1 KB = 1,000 bytes).
const DAY = 86_400; // seconds in a day

function si(n, unit = "") {
  const prefixes = ["", "K", "M", "G", "T", "P", "E"];
  let i = 0;
  while (Math.abs(n) >= 1000 && i < prefixes.length - 1) (n /= 1000), i++;
  const digits = n >= 100 ? 0 : n >= 10 ? 1 : 2; // three significant figures
  return `${n.toFixed(digits)} ${prefixes[i]}${unit}`.trimEnd();
}

function estimate({ dau, peakFactor, years, actions }) {
  const stores = {}; // bytes added per day, per kind of storage
  const rows = actions.map((a) => {
    const perDay = dau * a.perUserPerDay;
    const qps = perDay / DAY;
    for (const [store, bytes] of Object.entries(a.stores ?? {})) stores[store] = (stores[store] ?? 0) + perDay * bytes;
    return { name: a.name, perDay, qps, peak: qps * peakFactor, in: qps * (a.requestBytes ?? 0), out: qps * (a.responseBytes ?? 0) };
  });
  const total = (k) => rows.reduce((s, r) => s + r[k], 0);
  return { rows, stores, peakFactor, years, ingress: total("in"), egress: total("out") };
}

function report(e) {
  for (const r of e.rows)
    console.log(`${r.name.padEnd(13)}${si(r.perDay).padStart(8)}/day   avg ${(si(r.qps) + " QPS").padStart(10)}   peak ${(si(r.peak) + " QPS").padStart(10)}`);
  for (const [store, perDay] of Object.entries(e.stores))
    console.log(`${store.padEnd(13)}${si(perDay, "B").padStart(8)}/day   ${si(perDay * 365, "B")}/year   ${si(perDay * 365 * e.years, "B")} in ${e.years} years`);
  for (const [label, bytesPerSec] of [["Ingress", e.ingress], ["Egress", e.egress]])
    console.log(`${label.padEnd(13)}avg ${si(bytesPerSec, "B/s")} = ${si(bytesPerSec * 8, "bit/s")}   peak ${si(bytesPerSec * 8 * e.peakFactor, "bit/s")}`);
}

// Worked example: a photo-sharing app.
report(
  estimate({
    dau: 50e6,
    peakFactor: 3,
    years: 5,
    actions: [
      // 1 in 10 users posts one photo a day: the 2 MB original plus 400 KB of resized copies go to
      // object storage; a 1 KB metadata row goes to the database, kept on 3 replicas.
      { name: "Upload photo", perUserPerDay: 0.1, requestBytes: 2e6, stores: { "Object store": 2.4e6, Database: 1e3 * 3 } },
      { name: "Load feed", perUserPerDay: 10, responseBytes: 20e3 }, // JSON for ~10 posts
      { name: "View photo", perUserPerDay: 100, responseBytes: 300e3 }, // a medium-size image
    ],
  }),
);
Result
Upload photo   5.00 M/day   avg   57.9 QPS   peak    174 QPS
Load feed       500 M/day   avg 5.79 K QPS   peak 17.4 K QPS
View photo     5.00 G/day   avg 57.9 K QPS   peak  174 K QPS
Object store  12.0 TB/day   4.38 PB/year   21.9 PB in 5 years
Database      15.0 GB/day   5.47 TB/year   27.4 TB in 5 years
Ingress      avg 116 MB/s = 926 Mbit/s   peak 2.78 Gbit/s
Egress       avg 17.5 GB/s = 140 Gbit/s   peak 419 Gbit/s
What’s happening
  1. The assumptions are all in the estimate({...}) call, one per line, so the interviewer can challenge any one of them: 50 M DAU, a peak factor of 3, five years of retention, and three actions. Uploads are 0.1 per user per day (one in ten users posts once), feed loads 10, photo views 100.
  2. For each action, perDay = dau × perUserPerDay: uploads 50 M × 0.1 = 5 M, feed loads 500 M, photo views 5 G (5 billion; G is the SI prefix for 10^9). Then qps = perDay / 86,400: uploads 57.9, feed 5.79 K, views 57.9 K. Multiplying by the peak factor gives 174, 17.4 K and 174 K QPS.
  3. Storage is tracked per kind of store, because they are different systems: each upload adds 2.4 MB to the object store (the 2 MB original plus 400 KB of resized copies) and a 1 KB metadata row × 3 replicas = 3 KB to the database. 5 M × 2.4 MB = 12 TB a day, about 4.38 PB a year, 21.9 PB in five years. The database grows only 15 GB a day, 27.4 TB in five years.
  4. Bandwidth is QPS × bytes. Ingress is uploads: 57.9 × 2 MB = 116 MB/s, which is 926 Mbit/s once multiplied by 8. Egress is feed JSON plus images: 5.79 K × 20 KB + 57.9 K × 300 KB ≈ 17.5 GB/s, which is 140 Gbit/s on average and about 419 Gbit/s at peak.
  5. The helper si() divides by 1,000 until the number is under 1,000 and keeps three significant figures. Decimal units are deliberate: estimates use powers of ten, so 1 KB here is 1,000 bytes (the binary/decimal gap is measured below).
  6. What this demonstrates: a dozen lines of assumptions produce every number the design needs, and changing one assumption (say, 300 views per user) re-derives everything consistently. That is exactly what you'll do out loud when an interviewer says "what if usage triples?"
AdvancedReading the estimate back as decisions

What the numbers tell you

The estimate is only useful if you read it back as design decisions. For the photo app:

  • 174 K image views per second at peak, 419 Gbit/s of egress. No origin fleet should serve that directly. Images must come from a CDN, with the object store as its origin. This one number justifies the CDN box.
  • 17.4 K feed requests per second at peak. Too many for one server, comfortable for a fleet of stateless API servers behind a load balancer, with a cache in front of the database for the hot data (recent posts, follow lists).
  • 174 uploads per second at peak. Writes are small in count. A single well-tuned database primary takes thousands of small inserts a second (a rule of thumb; measure for your hardware), so the write path's challenge is the bytes, not the rows: 2 MB per upload should go straight to object storage, not through the API servers.
  • 12 TB a day, 21.9 PB in five years. Only object storage handles that economically, and lifecycle rules (moving old originals to cheaper storage classes) become a real cost lever.
  • 27.4 TB of metadata in five years, counting all three replicas: about 9 TB per copy. That is large for a single database node, so plan a partitioning key (for example by user id or post id) even if you start unsharded.
  • Reads to writes: 5 G views vs 5 M uploads, 1,000 to 1. The system is read-dominated, which is why caching and precomputation matter more than write scaling here.
AdvancedPowers of two and whiteboard shortcuts

Powers of two and the shortcuts people use

Two kinds of rounding happen at the whiteboard: treating binary units as decimal (a GiB as a GB), and rounding time constants (a day as 100,000 seconds). Both are fine if you know how big the error is. This prints it:

powers.mjs
// Powers of two against the decimal round numbers you use at the whiteboard.
const units = ["KiB vs KB", "MiB vs MB", "GiB vs GB", "TiB vs TB", "PiB vs PB"];
const fmt = (n) => n.toLocaleString("en-US", { maximumFractionDigits: 1 });
units.forEach((unit, i) => {
  const binary = 2 ** (10 * (i + 1));
  const decimal = 10 ** (3 * (i + 1));
  const off = ((binary / decimal - 1) * 100).toFixed(1);
  console.log(`${unit}  2^${10 * (i + 1)} = ${fmt(binary).padStart(25)}  is ${off.padStart(4)}% more than 10^${3 * (i + 1)}`);
});

// Time shortcuts: the exact numbers and the round ones people use.
const day = 24 * 60 * 60;
console.log();
for (const [label, exact, round, roundLabel] of [
  ["seconds per day", day, 1e5, "100,000"],
  ["seconds per month (30 d)", 30 * day, 2.5e6, "2.5 million"],
  ["seconds per year", 365 * day, Math.PI * 1e7, "π × 10^7"],
]) {
  const err = ((round / exact - 1) * 100).toFixed(1);
  console.log(`${label.padEnd(25)}${fmt(exact).padStart(11)}  ≈ ${roundLabel.padEnd(11)} (${err > 0 ? "+" : ""}${err}%)`);
}

// Requests per day → average requests per second.
console.log();
for (const perDay of [1e6, 10e6, 100e6, 1e9]) {
  console.log(`${fmt(perDay).padStart(13)} a day = ${fmt(perDay / day).padStart(8)} a second on average`);
}
Result
KiB vs KB  2^10 =                     1,024  is  2.4% more than 10^3
MiB vs MB  2^20 =                 1,048,576  is  4.9% more than 10^6
GiB vs GB  2^30 =             1,073,741,824  is  7.4% more than 10^9
TiB vs TB  2^40 =         1,099,511,627,776  is 10.0% more than 10^12
PiB vs PB  2^50 =     1,125,899,906,842,624  is 12.6% more than 10^15

seconds per day               86,400  ≈ 100,000     (+15.7%)
seconds per month (30 d)   2,592,000  ≈ 2.5 million (-3.5%)
seconds per year          31,536,000  ≈ π × 10^7    (-0.4%)

    1,000,000 a day =     11.6 a second on average
   10,000,000 a day =    115.7 a second on average
  100,000,000 a day =  1,157.4 a second on average
1,000,000,000 a day = 11,574.1 a second on average
What’s happening
  1. Each binary unit is 2^(10k) bytes and the matching decimal one is 10^(3k). The gap grows with size: 2.4% for kilobytes, 7.4% for gigabytes, 12.6% for petabytes. For an estimate that only needs to be within 2×, all of these are noise, but say "decimal units" once so nobody wonders.
  2. A day is 86,400 seconds. Rounding it to 100,000 makes division trivial (5 M a day ≈ 50 a second) at the cost of a 15.7% underestimate of QPS (you're dividing by a number that's too big). If you round, say so, or round the peak factor up to compensate.
  3. A 30-day month is about 2.5 million seconds (3.5% low), and a year is very nearly π × 10^7 seconds (0.4% low), a handy one for yearly storage and downtime.
  4. The last block is the conversion worth memorising: 1 M requests a day ≈ 12 per second, so 100 M a day ≈ 1,200 a second and 1 B a day ≈ 12,000 a second. Multiply by your peak factor afterwards.
AdvancedFrom estimates to servers, CDN and cache

From numbers to machines

Interviewers often follow up with "so how many servers?" or "how much cache?". The arithmetic is the same, but now you need a per-machine capacity, and that you must state as an assumption (the only honest source is a load test of your own service):

capacity.mjs
// From the photo-app estimates to hardware: servers, CDN offload and cache memory.
const DAY = 86_400;
const dau = 50e6;
const peakFactor = 3;

// 1. Feed API servers. ASSUMPTION: one server handles 1,000 feed requests/s (measure yours with a load test).
const feedPeak = ((dau * 10) / DAY) * peakFactor;
const perServer = 1000;
const targetUtilisation = 0.6; // leave headroom for spikes, GC pauses, deploys
const needed = Math.ceil(feedPeak / (perServer * targetUtilisation));
const zones = 3;
// Survive losing a whole zone: the remaining zones must carry the full peak.
const withZoneLoss = Math.ceil(needed / (zones - 1)) * zones;
console.log(`feed peak ${Math.round(feedPeak).toLocaleString("en-US")} QPS → ${needed} servers at 60% → ${withZoneLoss} across ${zones} zones (survives losing one)`);

// 2. Image egress and the CDN. Views: 100 per user per day at 300 KB.
const viewGbps = ((dau * 100) / DAY) * 300e3 * 8 / 1e9;
for (const hitRatio of [0.9, 0.95, 0.99]) {
  const origin = viewGbps * (1 - hitRatio);
  console.log(`CDN hit ratio ${(hitRatio * 100).toFixed(0)}%: origin serves ${origin.toFixed(1).padStart(4)} Gbit/s of ${viewGbps.toFixed(0)} (avg), ${(origin * peakFactor).toFixed(1).padStart(4)} at peak`);
}

// 3. Cache memory for recent post metadata (feeds mostly show the last few days).
const postsPerDay = dau * 0.1;
const bytesPerPost = 1e3;
for (const days of [1, 7, 30]) {
  const gb = (postsPerDay * bytesPerPost * days) / 1e9;
  console.log(`metadata for the last ${String(days).padStart(2)} ${days === 1 ? "day: " : "days:"} ${gb.toFixed(0).padStart(3)} GB in cache`);
}
Result
feed peak 17,361 QPS → 29 servers at 60% → 45 across 3 zones (survives losing one)
CDN hit ratio 90%: origin serves 13.9 Gbit/s of 139 (avg), 41.7 at peak
CDN hit ratio 95%: origin serves  6.9 Gbit/s of 139 (avg), 20.8 at peak
CDN hit ratio 99%: origin serves  1.4 Gbit/s of 139 (avg),  4.2 at peak
metadata for the last  1 day:    5 GB in cache
metadata for the last  7 days:  35 GB in cache
metadata for the last 30 days: 150 GB in cache
What’s happening
  1. Servers: peak feed load is 500 M / 86,400 × 3 = 17,361 QPS. Assuming one server handles 1,000 feed requests a second, and running each at 60% so there's headroom for spikes and deploys, you need ceil(17,361 / 600) = 29 servers.
  2. Zones change the count. Spread across 3 availability zones, losing a zone leaves two thirds of the fleet, which must still carry the peak. So each zone needs ceil(29 / 2) = 15 servers, 45 in total. That 55% extra is the price of surviving a zone failure, and it's the kind of number that shows you think about failure, not just load.
  3. CDN offload: photo views alone are 139 Gbit/s on average (the estimator's 140 also included the feed JSON). The origin only serves cache misses, so at a 90% hit ratio it serves 13.9 Gbit/s, at 99% just 1.4. Going from 90% to 99% cuts origin traffic 10×, which is why CDN hit ratio is a first-class metric.
  4. Cache memory: 5 M posts a day × 1 KB is 5 GB a day. Feeds mostly show recent posts, so caching the last 7 days takes 35 GB, which fits in the memory of one large cache node (in practice replicated or split across a small cluster). 30 days, 150 GB, is still a small cluster.
  5. Each line is assumption → arithmetic → decision, and every assumption (1,000 QPS per server, 60% target, 7 hot days) is printed in the code where it can be challenged.
AdvancedEstimating out loud at the whiteboard

Doing it out loud

At the whiteboard you won't run code, so use the same chain with round numbers and narrate it:

  • Say the assumption, then the arithmetic, then the decision. "50 million daily users, about 10 feed loads each: 500 million a day. A day is about 100 K seconds, so 5 K a second on average; call it 15 K at peak. That's a fleet of API servers and a cache."
  • Round once, in a known direction, and keep exponents explicit: 5 × 10^8 ÷ 10^5 = 5 × 10^3.
  • Write the results in a small table in a corner of the board. You will point at them during the deep dives ("this is why the image path needs a CDN").
  • Stop when the decision is clear. You don't need storage for a system whose storage is obviously trivial.
AdvancedFive classic estimation slips
Usage triples, photos grow

The product team now expects each user to view 300 photos a day (not 100), and to serve sharper images at 500 KB (not 300 KB). Everything else is the same. What happens to image egress, and what would you say about the design?

Show answer

Egress grows 5×: from about 139 Gbit/s to 694 Gbit/s on average, about 2 Tbit/s at peak.

quiz-egress.mjs
const dau = 50e6, peakFactor = 3;
const egressGbps = (viewsPerUser, bytesPerView) => ((dau * viewsPerUser) / 86_400) * bytesPerView * 8 / 1e9;

const before = egressGbps(100, 300e3);
const after = egressGbps(300, 500e3);
console.log(`before: ${before.toFixed(0)} Gbit/s average`);
console.log(`after:  ${after.toFixed(0)} Gbit/s average, ${((after * peakFactor) / 1000).toFixed(2)} Tbit/s at peak`);
console.log(`ratio:  ${(after / before).toFixed(2)}× (3× the views × 5/3 the bytes)`);
Result
before: 139 Gbit/s average
after:  694 Gbit/s average, 2.08 Tbit/s at peak
ratio:  5.00× (3× the views × 5/3 the bytes)
What’s happening
  1. Egress is views per second × bytes per view × 8. Views per second go from 50 M × 100 / 86,400 to 50 M × 300 / 86,400, three times as many.
  2. Bytes per view go from 300 KB to 500 KB, 5/3 as many. The two factors multiply: 3 × 5/3 = 5, so 139 → 694 Gbit/s, and 694 × 3 ≈ 2.08 Tbit/s at peak.
  3. Nothing about the shape of the design changes (a CDN was already required), but the cost does: CDN egress is usually priced per GB, so the bill scales 5× too. That is worth saying.
  4. It also makes optimisations worth their complexity: modern formats (AVIF/WebP), serving the size the device actually displays, and a higher CDN hit ratio. Every 1% of extra hit ratio now saves about 7 Gbit/s of origin traffic on average.
  5. The habit being tested: re-derive from the formula, don't guess. Two changed inputs, one multiplication each.

#Latency numbers to know

Added

Every design is bounded by a few physical costs: reading memory, reading storage, and sending a packet somewhere and back. They span nine orders of magnitude, from about a nanosecond for a CPU cache hit to a quarter of a second for a round trip to the other side of the planet. Knowing roughly where each operation sits tells you what can be on a request's critical path, what must be cached, and what must happen asynchronously.

The best-known list is "Latency numbers every programmer should know", popularised by Jeff Dean and Peter Norvig in the 2000s. Its structure is still right, but some numbers have moved a lot (solid-state storage is far faster than the spinning disks it listed), while others can't move at all: light in fibre still covers only about 1 km every 4.9 microseconds, because glass slows light to roughly two thirds of its speed in a vacuum. So here are current figures, each with its source, checked in October 2026.

OperationTypicalSource (checked October 2026)
CPU L1 cache hitabout 1 nsMeasured below on an Apple M5 Pro: 1.4 ns per dependent load
DRAM read (cache miss)about 100 nsMeasured below: 127 ns (including TLB misses). Chips and Cheese (December 2024) calls over 100 ns high for a desktop CPU
NVMe SSD, 4 KB random read60 µsMicron 9550 data-centre SSD spec: typical (median) read latency at queue depth 1
Round trip within one cloud zoneunder 1 ms; about 300 µsAWS Architecture Blog, "AZ affinity" (2021, updated April 2025): typically sub-millisecond, and its example uses 300 µs as the median
Cloud block storage readunder 500 µs (io2); single-digit ms (gp3)AWS EBS documentation: io2 Block Express average under 500 µs for 16 KiB I/O; gp3 "single-digit millisecond"
Round trip between zones in a regionabout 1–1.5 ms; single-digit msSame AWS post: single-digit ms; its example uses 1 ms minimum and 1.5 ms median
Object read, low-latency classsingle-digit msAWS S3 storage classes page: S3 Express One Zone. (AWS publishes no latency figure for S3 Standard; measure it)
Round trip, two regions in the same area8 msAzure P50, East US ↔ East US 2 (both Virginia)
Round trip, US east ↔ US west69 msAzure P50, East US ↔ West US
Round trip, US east ↔ UK78 msAzure P50, East US ↔ UK South
Round trip, US east ↔ Singapore224 msAzure P50, East US ↔ Southeast Asia
Round trip, UK ↔ Australia261 msAzure P50, UK South ↔ Australia East

The cross-region figures come from Microsoft's "Azure network round-trip latency statistics" page: medians of continuous probes between Azure regions over the 30 days ending July 30, 2026. Other clouds and the public internet will differ by some milliseconds, but not by orders of magnitude, because the distances don't change.

memory storage network in a region between regions L1 cache hit 1 ns DRAM read 100 ns NVMe SSD 4 KB read 60 µs Same-zone round trip 300 µs EBS io2 read 500 µs Zone-to-zone round trip 1.5 ms Nearby region round trip 8 ms US east ↔ US west 69 ms US east ↔ UK 78 ms US east ↔ Singapore 224 ms UK ↔ Australia 261 ms 1 ns 1 µs 1 ms 1 s
Logarithmic scale: each mark on the axis is 1,000× the one before. Memory sits in nanoseconds, storage and the data centre in microseconds to milliseconds, other regions in tens to hundreds of milliseconds.

These numbers are easiest to remember on a human scale. Pretend one nanosecond lasts one second, so everything is stretched a billion times:

latency-ladder.mjs
// Latency numbers to know (sources in the table above), and the same ladder in human time:
// pretend 1 nanosecond lasts 1 second, so everything is scaled up a billion times.
const NS = 1, US = 1e3, MS = 1e6;
const ladder = [
  ["L1 cache hit", 1 * NS],
  ["DRAM read (cache miss)", 100 * NS],
  ["NVMe SSD 4 KB random read", 60 * US],
  ["Round trip, same zone", 300 * US],
  ["EBS io2 read (average)", 500 * US],
  ["Round trip, zone to zone", 1.5 * MS],
  ["Round trip, nearby region", 8 * MS],
  ["Round trip, US east to west", 69 * MS],
  ["Round trip, US east to UK", 78 * MS],
  ["Round trip, US east to Singapore", 224 * MS],
  ["Round trip, UK to Australia", 261 * MS],
];

function real(ns) {
  if (ns < US) return `${ns} ns`;
  if (ns < MS) return `${ns / US} µs`;
  return `${ns / MS} ms`;
}

function human(seconds) {
  const units = [["years", 365 * 86400], ["days", 86400], ["hours", 3600], ["minutes", 60], ["seconds", 1]];
  const [name, size] = units.find(([, s]) => seconds >= s);
  const n = seconds / size;
  if (n === 1) return `1 ${name.slice(0, -1)}`; // "1 second", not "1 seconds"
  return `${n.toFixed(n < 10 ? 1 : 0)} ${name}`;
}

for (const [what, ns] of ladder) {
  // 1 ns → 1 s, so the human-time value in seconds is just the number of nanoseconds.
  console.log(`${what.padEnd(33)}${real(ns).padStart(8)}   ≈ ${human(ns)}`);
}
Result
L1 cache hit                         1 ns   ≈ 1 second
DRAM read (cache miss)             100 ns   ≈ 1.7 minutes
NVMe SSD 4 KB random read           60 µs   ≈ 17 hours
Round trip, same zone              300 µs   ≈ 3.5 days
EBS io2 read (average)             500 µs   ≈ 5.8 days
Round trip, zone to zone           1.5 ms   ≈ 17 days
Round trip, nearby region            8 ms   ≈ 93 days
Round trip, US east to west         69 ms   ≈ 2.2 years
Round trip, US east to UK           78 ms   ≈ 2.5 years
Round trip, US east to Singapore   224 ms   ≈ 7.1 years
Round trip, UK to Australia        261 ms   ≈ 8.3 years
What’s happening
  1. The ladder array holds the figures from the table, in nanoseconds. NS, US and MS are just multipliers, so 60 * US reads like the spec sheet.
  2. real() prints each value back in its natural unit; human() treats the same number as seconds: if 1 ns lasts 1 s, then 100 ns lasts 100 s and the code only has to pick a readable unit (find returns the largest unit that fits).
  3. On that scale an L1 hit takes a second and a DRAM read under two minutes: memory is effectively instant for request handling.
  4. An NVMe read takes 17 hours and a round trip to another machine in the same zone 3.5 days. Notice the order: on modern hardware, a local SSD read is faster than asking another server in the same data centre. "Disk is slow" is a spinning-disk-era rule.
  5. A round trip to another zone takes 17 days, and across the Atlantic 2.5 years. Every cross-region call in a request is a multi-year detour on this scale, which is why designs keep the request path inside one region and replicate data asynchronously between regions.
AdvancedWhat the latency numbers imply

What the numbers imply

  • Memory is thousands of times faster than the network. A value in your process's memory costs about 100 ns; the same value in Redis in the same zone costs a ~300 µs round trip. An in-process cache in front of a shared cache is worth it for very hot keys.
  • Hops add up; round trips multiply. A request that makes 20 sequential database calls to another zone spends 20 × 1.5 ms = 30 ms on the network alone. Batch them into one query or run independent calls in parallel (modelled below).
  • Zones are cheap to cross; regions are not. Synchronous replication across zones (about 1–2 ms) is affordable for a database commit. Across distant regions (70–260 ms) it adds a whole cross-region round trip to every write, many times the cost of the commit itself, so cross-region replication is usually asynchronous, which means a region failure can lose the last few seconds of writes.
  • A new connection costs extra round trips. TCP's handshake takes one round trip and TLS 1.3 another, before the first request byte. From Virginia to London that's about 2 × 78 = 156 ms before the request even leaves, so keep connections open and reused (HTTP keep-alive, connection pools), and terminate TLS close to users (at a CDN edge).
  • Physics sets a floor no code can beat. Users in Sydney talking to a server in London pay at least the light-in-fibre time to cross 17,000 km, about 167 ms per round trip. The fix is to move the data closer (CDN, regional replicas), not to optimise the code.
AdvancedThe fibre floor under cross-region latency

The floor under cross-region latency

How close to physics are real networks? This model computes the great-circle distance between the cities hosting those Azure regions, turns it into the minimum possible round trip in fibre, and compares it with the measured medians:

fibre-floor.mjs
// The physics floor under cross-region latency: light in glass fibre covers about 1 km every 4.9 µs.
const US_PER_KM = 4.9; // one way, refractive index ≈ 1.47

const places = {
  Virginia: [37.54, -77.44], // Azure East US (approximate city coordinates)
  California: [37.77, -122.42], // West US
  London: [51.51, -0.13], // UK South
  Singapore: [1.35, 103.82], // Southeast Asia
  Sydney: [-33.87, 151.21], // Australia East
};

// Great-circle distance: the shortest path over the Earth's surface (haversine formula).
function km([lat1, lon1], [lat2, lon2]) {
  const rad = Math.PI / 180;
  const a = Math.sin(((lat2 - lat1) * rad) / 2) ** 2 +
    Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(((lon2 - lon1) * rad) / 2) ** 2;
  return 2 * 6371 * Math.asin(Math.sqrt(a));
}

// Measured P50 round trips between those Azure regions (30 days to July 30, 2026).
const measured = [
  ["Virginia", "California", 69],
  ["Virginia", "London", 78],
  ["Virginia", "Singapore", 224],
  ["London", "Sydney", 261],
];

for (const [a, b, ms] of measured) {
  const d = km(places[a], places[b]);
  const floor = (2 * d * US_PER_KM) / 1000; // there and back, in ms
  console.log(`${`${a} ↔ ${b}`.padEnd(22)}${Math.round(d).toLocaleString("en-US").padStart(7)} km  ` +
    `floor ${floor.toFixed(0).padStart(3)} ms  measured ${String(ms).padStart(3)} ms  (${(ms / floor).toFixed(2)}× the floor)`);
}
Result
Virginia ↔ California   3,920 km  floor  38 ms  measured  69 ms  (1.80× the floor)
Virginia ↔ London       6,024 km  floor  59 ms  measured  78 ms  (1.32× the floor)
Virginia ↔ Singapore   15,689 km  floor 154 ms  measured 224 ms  (1.46× the floor)
London ↔ Sydney        16,994 km  floor 167 ms  measured 261 ms  (1.57× the floor)
What’s happening
  1. US_PER_KM = 4.9: light in silica fibre (refractive index about 1.47) travels about 204,000 km/s, so 1 km takes about 4.9 µs one way.
  2. km() is the haversine formula: the shortest path over a sphere of radius 6,371 km between two latitude/longitude points. City coordinates are approximate, which is fine at this scale.
  3. The floor is 2 × distance × 4.9 µs, there and back: Virginia ↔ London is 6,024 km, so at least 59 ms. Azure measures 78 ms, only 1.32× the floor, because transatlantic cables run almost straight.
  4. Virginia ↔ California is 1.80× the floor: overland fibre follows railways and roads, not great circles, and passes through more routers. Long Pacific and Indian Ocean routes sit around 1.5×.
  5. The takeaway: real round trips are typically 1.3–1.8× the straight-line fibre time, so you can estimate any city pair in your head from its distance: about 1 ms of round trip per 100 km, times 1.5.
AdvancedAdding up a request's latency budget

A request's latency budget

When a requirement says "feed p99 under 300 ms", you can check a design by adding up its hops. This model compares a naive feed request with a better version. The network times come from the table; the work at each hop is an assumption, written next to it.

request-budget.mjs
// Adding up a request's latency from its hops. Network times are the round trips from the ladder;
// the work done at each hop (a cache GET, a DB query) is an ASSUMPTION written next to it.
const SAME_ZONE = 0.3, CROSS_ZONE = 1.5, US_TO_UK = 78; // ms, round trips

const hop = (name, network, work) => ({ name, ms: network + work });
const times = (n, h) => ({ name: `${n} × ${h.name}`, ms: n * h.ms }); // n calls, one after another
const seq = (name, steps) => ({ name, ms: steps.reduce((s, x) => s + x.ms, 0), steps, how: "total" });
const par = (name, steps) => ({ name, ms: Math.max(...steps.map((x) => x.ms)), steps, how: "(slowest wins)" });

const naive = seq("naive feed request", [
  hop("session from cache in another zone", CROSS_ZONE, 0.2),
  times(20, hop("post from the DB, one query each", CROSS_ZONE, 1)),
  hop("ranking service in the UK region", US_TO_UK, 10),
]);

const better = seq("better feed request", [
  hop("session from cache in the same zone", SAME_ZONE, 0.2),
  par("three independent calls at once", [
    hop("20 posts in one batched query", CROSS_ZONE, 4),
    hop("ranking service in this region", CROSS_ZONE, 10),
    hop("like counts from cache", SAME_ZONE, 0.5),
  ]),
]);

function show(node, depth = 0) {
  const label = "  ".repeat(depth) + node.name;
  console.log(`${label.padEnd(42)}${node.ms.toFixed(1).padStart(6)} ms ${node.how ?? ""}`.trimEnd());
  for (const step of node.steps ?? []) show(step, depth + 1);
}

show(naive);
show(better);
console.log(`speed-up: ${(naive.ms / better.ms).toFixed(1)}×`);
Result
naive feed request                         139.7 ms total
  session from cache in another zone         1.7 ms
  20 × post from the DB, one query each     50.0 ms
  ranking service in the UK region          88.0 ms
better feed request                         12.0 ms total
  session from cache in the same zone        0.5 ms
  three independent calls at once           11.5 ms (slowest wins)
    20 posts in one batched query            5.5 ms
    ranking service in this region          11.5 ms
    like counts from cache                   0.8 ms
speed-up: 11.6×
What’s happening
  1. hop() is one call: network round trip plus the work done there. seq() adds its children (they run one after another), par() takes the maximum of its children (they run at the same time, so the slowest one decides). times(20, …) is 20 identical calls in a row.
  2. The naive request reads the session from a cache in another zone (1.5 + 0.2 = 1.7 ms), then makes 20 separate DB queries in sequence, an N+1 pattern (20 × (1.5 + 1) = 50 ms), then calls a ranking service in another region (78 + 10 = 88 ms). Total: 139.7 ms, most of it waiting on the network.
  3. The better request keeps the cache in the same zone (0.5 ms), fetches the 20 posts in one batched query (5.5 ms), keeps ranking in the same region, and runs the three independent calls in parallel, so that whole group costs only its slowest member, ranking at 11.5 ms.
  4. Total 12.0 ms, an 11.6× speed-up. Almost all of it comes from the shape of the calls, not from doing less work: fewer round trips, shorter round trips, and overlapping round trips.
  5. This is the reasoning to say out loud in a deep dive: "the feed path makes N calls; batch them", "this dependency is cross-region; keep it out of the synchronous path or replicate its data here".
AdvancedFan-out makes the tail the common case

Averages hide the slow requests that users actually notice, so latency requirements are written as percentiles (p99: 99% of requests are faster). Fan-out makes this much worse. In "The Tail at Scale" (Communications of the ACM, 2013), Jeff Dean and Luiz André Barroso describe servers that usually answer in 10 ms but take a second for 1 request in 100: a user request that waits for 100 of them is slow 63% of the time. This model checks that with the formula and with a seeded simulation:

tail.mjs
// Fan-out makes the tail the common case: a request that waits for N servers is slow if ANY of them is.
// Each server: usually 10 ms, but 1 request in 100 takes 1,000 ms (its p99), independently.
function mulberry32(seed) { // small seeded PRNG, so the simulation prints the same numbers every run
  return () => {
    let t = (seed = (seed + 0x6d2b79f5) | 0); // stay a 32-bit integer
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(2026);
const serverLatency = () => (rand() < 0.01 ? 1000 : 10);

const TRIALS = 100_000;
for (const fanOut of [1, 10, 100]) {
  let slow = 0;
  for (let t = 0; t < TRIALS; t++) {
    let worst = 0;
    for (let s = 0; s < fanOut; s++) worst = Math.max(worst, serverLatency());
    if (worst >= 1000) slow++;
  }
  const formula = 1 - 0.99 ** fanOut; // P(at least one slow) = 1 - P(none slow)
  console.log(`fan-out ${String(fanOut).padStart(3)}: ${(formula * 100).toFixed(1).padStart(4)}% slow by formula, ` +
    `${((slow / TRIALS) * 100).toFixed(1).padStart(4)}% in ${TRIALS.toLocaleString("en-US")} simulated requests`);
}
Result
fan-out   1:  1.0% slow by formula,  1.0% in 100,000 simulated requests
fan-out  10:  9.6% slow by formula,  9.4% in 100,000 simulated requests
fan-out 100: 63.4% slow by formula, 63.4% in 100,000 simulated requests
What’s happening
  1. mulberry32 is a tiny seeded random number generator, so the simulation prints the same numbers on every run. Its state is kept a 32-bit integer with | 0 (without it, the state grows past 2^53 and the generator quietly loses randomness after a few million draws, which skews long simulations).
  2. serverLatency() returns 1000 ms with probability 1% and 10 ms otherwise: a server whose p99 is a second.
  3. For each request, the code asks fanOut servers and keeps the slowest answer, because the request can't finish until all of them have answered.
  4. The formula: the request is fast only if every server is fast, with probability 0.99^N, so slow with probability 1 - 0.99^N. For N = 1 that's 1%, for 10 it's 9.6%, for 100 it's 63.4%. The simulation of 100,000 requests agrees (9.4% and 63.4%, within sampling noise).
  5. What it means for design: if a page fans out to many backends, each backend's p99 becomes the page's typical case. Remedies include fewer fan-out calls, hedged requests (send a duplicate after a short wait and take the first answer), and tight per-call timeouts with a partial result.
AdvancedMeasuring memory latency on this Mac

Measuring memory latency yourself

The first two rows of the table were measured on the Mac this chapter was written on. A pointer chase makes every memory read depend on the previous one, so the CPU can't overlap them, and the time per hop is the real latency. It is not a Result block because the timings vary slightly between runs and machines:

pointer-chase.js
// Measures the cost of one dependent memory load: each read's address comes from the previous read,
// so the CPU can't overlap them. Small arrays fit in cache; big ones force a trip to DRAM.
function chase(bytes, hops = 20_000_000) {
  const n = bytes / 4;
  const next = new Int32Array(n);
  // Sattolo's shuffle: one big random cycle through every slot (seeded, so runs are comparable).
  let seed = 42;
  const rand = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 2 ** 32);
  for (let i = 0; i < n; i++) next[i] = i;
  for (let i = n - 1; i > 0; i--) {
    const j = Math.floor(rand() * i);
    [next[i], next[j]] = [next[j], next[i]];
  }
  let p = 0;
  for (let i = 0; i < 1_000_000; i++) p = next[p]; // warm up
  const t0 = process.hrtime.bigint();
  for (let i = 0; i < hops; i++) p = next[p];
  const ns = Number(process.hrtime.bigint() - t0) / hops;
  return { ns, p };
}

for (const [label, bytes] of [["16 KiB (L1)", 16 << 10], ["1 MiB (L2)", 1 << 20], ["1 GiB (DRAM)", 1 << 30]]) {
  const { ns } = chase(bytes);
  console.log(`${label.padEnd(14)} ${ns.toFixed(1)} ns per dependent load`);
}
Measured: Apple M5 Pro, Node 26.7, three runs (they agreed to ±0.6 ns; later re-runs gave 125–128 ns on the DRAM line)
16 KiB (L1)    1.4 ns per dependent load
1 MiB (L2)     3.6 ns per dependent load
1 GiB (DRAM)   127.4 ns per dependent load
What’s happening
  1. next is an array where each slot holds the index of the next slot to visit. Sattolo's shuffle turns it into one single cycle through every slot in random order, so the chase never gets stuck in a small loop that would fit in cache.
  2. The timed loop does p = next[p] 20 million times. Each read needs the previous result as its address, so the CPU can't start the next load early. That is what makes this a latency test rather than a bandwidth test.
  3. A 16 KiB array fits in the L1 cache: 1.4 ns per load. A 1 MiB array lives in L2: 3.6 ns. A 1 GiB array is far bigger than every cache, so almost every load goes to DRAM: 127 ns. That also includes a TLB miss per load (the page-table lookup that maps virtual to physical addresses), which a random walk over 1 GiB triggers constantly.
  4. The ratios are the point: DRAM is about 100× slower than L1, and still about 2,000× faster than a same-zone network round trip.
A 100 ms budget across the Atlantic

A service in Virginia must, on every request, call a service in London and still meet a p99 of 100 ms end to end. The London service itself takes 10 ms. Is it possible, and what would you do?

Show answer

Only with warm connections, and with almost no margin. The better answer is to remove the cross-region call from the request path.

What’s happening
  1. The network alone is one round trip, 78 ms median (Azure, East US ↔ UK South). Add London's 10 ms: 88 ms at the median, before any work in Virginia.
  2. That's a median. The p99 round trip is higher (queues, retransmits), so a p99 under 100 ms leaves essentially no budget for Virginia's own work.
  3. With a new connection per request it's hopeless: TCP's handshake (1 round trip) + TLS 1.3 (1 round trip) + the request (1 round trip) = 3 × 78 = 234 ms. So at minimum, keep a pool of warm connections.
  4. The design fix: replicate the data London serves into a Virginia replica (asynchronously; accept seconds of staleness), or cache its responses in Virginia, or make the call asynchronous so the user isn't waiting on it.
  5. The general lesson: compare the budget to the physical floor first. If the floor alone uses most of the budget, no amount of code optimisation will help.

#Availability, SLAs, SLOs and SLIs

Added

Availability is the fraction of time, or of requests, for which a service works. It is usually quoted in "nines": 99.9% is "three nines", 99.99% "four nines". Each extra nine allows ten times less downtime, and usually costs far more than ten times the effort, because the last nines require removing humans from the recovery path.

Three terms, as defined in Google's Site Reliability Engineering book, keep the conversation precise:

  • An SLI (service level indicator) is a carefully defined measurement of the service, for example "the fraction of feed requests that return a non-error response within 300 ms".
  • An SLO (service level objective) is your target for an SLI over a window, for example "99.9% of feed requests, measured over 30 days".
  • An SLA (service level agreement) is a contract with users that says what happens (usually service credits) if the promised level is missed.

A good mental model is driving. The SLI is your speedometer: a measurement, nothing more. The SLO is the limit you set yourself. The SLA is the legal limit with a fine attached. You set your own limit below the legal one so you never pay the fine: internal SLOs are stricter than external SLAs. AWS works this way: S3 Standard is designed for 99.99% availability but its SLA promises 99.9% (S3 storage classes page, checked October 2026).

The first thing to know by heart is what each target allows. This prints the downtime budget per year, 30-day month, week and day:

nines.mjs
// How much downtime each availability target allows. A 30-day month, a 365-day year.
const MINUTE = 60, HOUR = 3600, DAY = 86_400;
const periods = [["per year", 365 * DAY], ["per month", 30 * DAY], ["per week", 7 * DAY], ["per day", DAY]];

function duration(seconds) {
  if (seconds >= DAY) return `${(seconds / DAY).toFixed(1)} d`;
  if (seconds >= HOUR) return `${(seconds / HOUR).toFixed(1)} h`;
  if (seconds >= MINUTE) return `${(seconds / MINUTE).toFixed(1)} min`;
  return `${seconds.toFixed(1)} s`;
}

console.log("availability".padEnd(14) + periods.map(([p]) => p.padStart(11)).join(""));
for (const target of ["90", "99", "99.5", "99.9", "99.95", "99.99", "99.999"]) {
  const down = 1 - Number(target) / 100; // fraction of time allowed to be down
  console.log(`${(target + "%").padEnd(14)}${periods.map(([, secs]) => duration(down * secs).padStart(11)).join("")}`);
}
Result
availability     per year  per month   per week    per day
90%                36.5 d      3.0 d     16.8 h      2.4 h
99%                 3.7 d      7.2 h      1.7 h   14.4 min
99.5%               1.8 d      3.6 h   50.4 min    7.2 min
99.9%               8.8 h   43.2 min   10.1 min    1.4 min
99.95%              4.4 h   21.6 min    5.0 min     43.2 s
99.99%           52.6 min    4.3 min    1.0 min      8.6 s
99.999%           5.3 min     25.9 s      6.0 s      0.9 s
What’s happening
  1. down = 1 - target / 100 is the fraction of time the service may be down: 0.001 for 99.9%. Each column multiplies it by a period in seconds and duration() picks a readable unit.
  2. 99.9% allows 43.2 minutes a month, about 8.8 hours a year. That is enough for a person to be paged, diagnose and fix a problem once or twice a month.
  3. 99.99% allows 4.3 minutes a month (52.6 minutes a year). A human can barely acknowledge a page in that time, so failover has to be automatic.
  4. 99.999% allows 25.9 seconds a month (5.3 minutes a year). Only fully automated detection and recovery fits, and even a routine deploy that blips for a few seconds is a measurable part of the budget.
  5. Reading the table the other way is just as useful: if your deploys cause one minute of errors each and you deploy daily, you're spending 30 minutes a month, so you cannot honestly promise more than three nines.
AdvancedReal cloud SLAs to quote

Real SLAs to quote

Cloud SLAs make good reference points because they're public. From the current AWS pages (checked October 2026):

ServiceCommitmentNote
Amazon EC2, region level99.99% monthly uptimeOnly when instances run in two or more availability zones
Amazon EC2, single instance99.5% monthlyOne instance can be down about 3.6 hours a month within the SLA
Amazon S3 StandardDesigned for 99.99% availability; SLA 99.9%Durability is designed to exceed 99.999999999% (eleven nines)
Amazon S3 Express One ZoneDesigned for 99.95%; SLA 99.9%Single-zone storage, traded for single-digit-ms latency

Two lessons hide in that table. First, redundancy is what buys availability: one instance gets 99.5%, instances across zones get 99.99%. Second, availability and durability are different promises: S3 may occasionally refuse a request (availability), but data written to it is designed to be essentially never lost (durability).

AdvancedSerial and parallel availability in code

Serial and parallel parts

A system's availability comes from its parts, and there are only two ways to combine them:

  • In series (every part must work, like a request passing through a load balancer, then an app server, then a database): multiply the availabilities. The result is always lower than the weakest part.
  • In parallel (any one copy will do, like three interchangeable app servers): the system is down only if all copies are down at once, so availability is 1 − (1 − a)^n. The result is higher than any single copy.
In series: every part must be up Load balancer 99.99% App server 99.9% Database 99.95% = 99.84% 841 min/year down In parallel: any one copy will do Load balancer 99.99% App 1 99.5% App 2 99.5% App 3 99.5% DB primary 99.95% DB standby 99.95% failover = 99.99% 53 min/year down, now set by the LB
Series multiplies availabilities down; parallel copies multiply the chance of failure down. The weakest serial part ends up setting the total.

The model computes both diagrams, plus a warning about long dependency chains:

compose.mjs
// Availability of a system from the availability of its parts (assuming parts fail independently).
const serial = (...parts) => parts.reduce((a, p) => a * p, 1); // every part must be up
const parallel = (...parts) => 1 - parts.reduce((a, p) => a * (1 - p), 1); // at least one must be up
const pct = (a) => `${(a * 100).toFixed(7).replace(/0+$/, "")}%`.padEnd(12);
const downPerYear = (a) => {
  const minutes = (1 - a) * 365 * 24 * 60;
  return minutes >= 1 ? `${minutes.toFixed(0)} min/year down` : `${(minutes * 60).toFixed(1)} s/year down`;
};

// Serial: a request passes through all three.
const lb = 0.9999, app = 0.999, db = 0.9995;
const chain = serial(lb, app, db);
console.log(`serial   LB × app × DB         ${pct(chain)}  ${downPerYear(chain)}`);

// Parallel: three copies, any one can serve. Each is a single instance at 99.5%.
const one = 0.995;
for (const n of [1, 2, 3]) {
  const copies = parallel(...Array(n).fill(one));
  console.log(`parallel ${n} × instance at 99.5% ${pct(copies)}  ${downPerYear(copies)}`);
}

// A whole design: LB → 3 app instances (any one will do) → DB primary with a standby.
const design = serial(lb, parallel(one, one, one), parallel(0.9995, 0.9995));
console.log(`design   LB → 3 apps → DB pair ${pct(design)}  ${downPerYear(design)}`);

// Ten dependencies in a chain, each a respectable 99.9%:
const ten = serial(...Array(10).fill(0.999));
console.log(`serial   10 × 99.9% deps       ${pct(ten)}  ${downPerYear(ten)}`);
Result
serial   LB × app × DB         99.840065%    841 min/year down
parallel 1 × instance at 99.5% 99.5%         2628 min/year down
parallel 2 × instance at 99.5% 99.9975%      13 min/year down
parallel 3 × instance at 99.5% 99.9999875%   3.9 s/year down
design   LB → 3 apps → DB pair 99.9899625%   53 min/year down
serial   10 × 99.9% deps       99.004488%    5232 min/year down
What’s happening
  1. serial multiplies: 0.9999 × 0.999 × 0.9995 = 0.99840065, so 99.84%, about 841 minutes (14 hours) a year. That's worse than every individual part, including the 99.9% app server.
  2. parallel multiplies the chances of being down: one instance at 99.5% is down 0.5% of the time; two are both down 0.005² = 0.000025 of the time (99.9975%); three, 0.005³, leaves only about 4 seconds a year.
  3. The full design puts those pieces in series: LB, then the parallel app trio, then the primary/standby pair (1 − 0.0005² = 99.999975%). The total is 99.99%, 53 minutes a year, almost exactly the load balancer's own 99.99%. Once everything else is redundant, the single non-redundant part sets the availability, so that's the next thing to make redundant (managed load balancers are themselves spread across zones).
  4. The last line is the trap in microservice architectures: ten dependencies in a chain, each a respectable 99.9%, give only 99.0%, 87 hours of downtime a year. Each synchronous dependency you add costs availability, unless you can serve a degraded response when it's down.
  5. All of this assumes parts fail independently. Two app servers in the same rack, zone or deploy fail together, and then the parallel formula is far too optimistic (simulated next).
AdvancedChecking the formulas by simulation

The formulas are easy to apply and easy to misapply, so this model checks them with a simulation, minute by minute for 200 years. It also introduces the usual way to describe one part's availability: MTBF (mean time between failures) and MTTR (mean time to repair), giving availability = MTBF / (MTBF + MTTR).

simulate.mjs
// Checks the formulas by simulation: components fail and get repaired at random (seeded), minute by minute.
// Availability of one part = MTBF / (MTBF + MTTR): mean time between failures over the whole cycle.
function mulberry32(seed) {
  return () => {
    let t = (seed = (seed + 0x6d2b79f5) | 0); // stay a 32-bit integer
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(7);
const HOUR = 60, DAY = 24 * HOUR, MINUTES = 200 * 365 * DAY; // simulate 200 years

const part = (mtbf, mttr) => ({ mtbf, mttr, up: true, upMinutes: 0 });
const step = (p) => { // each minute: an up part may fail, a down part may come back
  if (p.up ? rand() < 1 / p.mtbf : rand() < 1 / p.mttr) p.up = !p.up;
  if (p.up) p.upMinutes++;
};

const appA = part(30 * DAY, 2 * HOUR), appB = part(30 * DAY, 2 * HOUR);
const db = part(90 * DAY, 1 * HOUR);
const zone = part(365 * DAY, 4 * HOUR); // a zone outage takes down everything in that zone
let pairUp = 0, systemUp = 0, sameZoneUp = 0;
for (let m = 0; m < MINUTES; m++) {
  [appA, appB, db, zone].forEach(step);
  const pair = appA.up || appB.up;
  if (pair) pairUp++;
  if (pair && db.up) systemUp++;
  if (pair && db.up && zone.up) sameZoneUp++; // both apps and the DB in ONE zone
}

const formula = (p) => p.mtbf / (p.mtbf + p.mttr);
const a = formula(appA), d = formula(db), z = formula(zone);
const show = (label, f, sim) =>
  console.log(`${label.padEnd(30)} formula ${(f * 100).toFixed(4)}%  simulated ${((sim / MINUTES) * 100).toFixed(4)}%`);
show("one app server", a, appA.upMinutes);
show("two app servers (parallel)", 1 - (1 - a) ** 2, pairUp);
show("app pair then DB (serial)", (1 - (1 - a) ** 2) * d, systemUp);
show("same, all in one zone", (1 - (1 - a) ** 2) * d * z, sameZoneUp);
Result
one app server                 formula 99.7230%  simulated 99.7216%
two app servers (parallel)     formula 99.9992%  simulated 99.9993%
app pair then DB (serial)      formula 99.9530%  simulated 99.9533%
same, all in one zone          formula 99.9073%  simulated 99.9080%
What’s happening
  1. Each part flips between up and down at random: an up part fails in a given minute with probability 1 / MTBF, a down part is repaired with probability 1 / MTTR. On average that produces exactly the MTBF and MTTR you asked for. The generator is seeded, so the output is the same on every run.
  2. One app server fails about once a month (MTBF 30 days) and takes 2 hours to fix: 43,200 / (43,200 + 120) = 99.7230%. The simulation measures 99.7216%, a match to three decimal places.
  3. Two app servers in parallel: the formula says 1 − (1 − 0.99723)² = 99.9992%, and the simulation agrees. Putting the database (99.9537% from its 90-day MTBF and 1-hour MTTR) in series gives 99.9530% by formula, 99.9533% simulated.
  4. The last line adds a zone outage (once a year, 4 hours) that takes down everything in the zone at once. If both app servers and the database live in one zone, that shared failure sits in series with everything: 99.9073%, double the downtime, even though the two-server pair on its own looked like five nines.
  5. That's the practical rule: redundancy only counts if the copies fail independently. Spread copies across zones (and for the highest targets, regions), and deploy to them one at a time so a bad release can't take them all down together.
AdvancedError budgets and burn rates

Error budgets

An SLO of 99.9% means you are allowed to fail 0.1% of the time. That allowance is the error budget, and the SRE practice is to spend it deliberately: when there's budget left, ship features and take risks; when it's spent, freeze risky changes and work on reliability. It turns "is it reliable enough?" from an argument into a number.

error-budget.mjs
// An error budget is the failure the SLO allows: (1 - SLO) of the requests in the window.
const SLO = 0.999; // 99.9% of requests succeed, measured over 30 days
const WINDOW_HOURS = 30 * 24;
const avgQps = 500;
const requests = avgQps * WINDOW_HOURS * 3600;
const budget = requests * (1 - SLO);
console.log(`window: ${requests.toLocaleString("en-US")} requests → budget ${Math.round(budget).toLocaleString("en-US")} failed requests`);

// Incidents spend the budget. Each: how long it lasted, the error rate, the traffic during it.
const incidents = [
  { what: "bad deploy, rolled back", minutes: 12, errorRate: 0.3, qps: 900 },
  { what: "DB failover", minutes: 2, errorRate: 1.0, qps: 500 },
  { what: "slow leak of 0.5% errors", minutes: 3 * 24 * 60, errorRate: 0.005, qps: 500 },
];
let spent = 0;
for (const i of incidents) {
  const failed = i.minutes * 60 * i.qps * i.errorRate;
  spent += failed;
  console.log(`${i.what.padEnd(26)} ${Math.round(failed).toLocaleString("en-US").padStart(9)} failed  ` +
    `(${((failed / budget) * 100).toFixed(1).padStart(4)}% of budget, ${((spent / budget) * 100).toFixed(1)}% used so far)`);
}

// Burn rate: how many times faster than "exactly on budget" errors are arriving.
// At burn rate 1 the budget lasts exactly the window; at 14.4 it's gone in 720 / 14.4 = 50 hours.
for (const errorRate of [0.001, 0.005, 0.0144, 0.1]) {
  const burn = errorRate / (1 - SLO);
  console.log(`error rate ${(errorRate * 100).toFixed(2).padStart(5)}%  burn rate ${burn.toFixed(1).padStart(5)}  ` +
    `a full budget lasts ${(WINDOW_HOURS / burn).toFixed(1).padStart(5)} hours`);
}
Result
window: 1,296,000,000 requests → budget 1,296,000 failed requests
bad deploy, rolled back      194,400 failed  (15.0% of budget, 15.0% used so far)
DB failover                   60,000 failed  ( 4.6% of budget, 19.6% used so far)
slow leak of 0.5% errors     648,000 failed  (50.0% of budget, 69.6% used so far)
error rate  0.10%  burn rate   1.0  a full budget lasts 720.0 hours
error rate  0.50%  burn rate   5.0  a full budget lasts 144.0 hours
error rate  1.44%  burn rate  14.4  a full budget lasts  50.0 hours
error rate 10.00%  burn rate 100.0  a full budget lasts   7.2 hours
What’s happening
  1. At 500 requests a second for 30 days the service handles 1,296,000,000 requests, and 0.1% of that, 1,296,000 failed requests, is the month's budget. (A time-based SLO would say the same thing as 43.2 minutes.)
  2. A bad deploy that fails 30% of requests for 12 minutes at a 900 QPS peak costs 12 × 60 × 900 × 0.3 = 194,400 failures, 15% of the month's budget. Fast rollback is what kept it at 15%.
  3. A database failover with 2 minutes of total failure costs only 4.6%. The expensive incident is the quiet one: 0.5% of requests failing for three days uses 50% of the budget even though nobody was paged.
  4. The burn rate is the error rate divided by the allowed rate: 0.5% errors against a 0.1% allowance is a burn rate of 5, and the whole budget would last 720 / 5 = 144 hours. Google's SRE Workbook recommends paging when 2% of a 30-day budget burns in one hour, which is a burn rate of 14.4 (the whole budget gone in 50 hours).
  5. In an interview, error budgets answer "how do you balance shipping speed with reliability?" with a mechanism instead of an opinion.
AdvancedGotcha: dependencies cap your availability
Two regions, active-active

You run the whole stack in two regions, each 99.9% available, active-active (either region can serve all users). A global routing layer (DNS or a traffic manager) in front of both is 99.99% available. What is the total availability, assuming the regions fail independently?

Show answer

About 99.99%, around 53 minutes a year, set by the routing layer, not by the regions.

quiz-regions.mjs
const region = 0.999; // each region's whole stack
const routing = 0.9999; // global DNS / traffic manager in front of both
const both = 1 - (1 - region) ** 2; // either region can serve everyone
const total = both * routing; // routing is in series with the pair
const pct = (a) => `${(a * 100).toFixed(5)}%`;
console.log(`two regions, independent: ${pct(both)}`);
console.log(`with routing in front:    ${pct(total)}`);
console.log(`downtime per year:        ${((1 - total) * 525_600).toFixed(0)} minutes`);
Result
two regions, independent: 99.99990%
with routing in front:    99.98990%
downtime per year:        53 minutes
What’s happening
  1. The two regions are in parallel: both must be down at once for an outage, 0.001 × 0.001 = 10^-6, so the pair is 99.9999%.
  2. The routing layer is in series with the pair: every request passes through it. 0.999999 × 0.9999 ≈ 0.9998990, so 99.98990%.
  3. The pair contributes almost nothing to the downtime; the routing layer's 0.01% dominates: 53 minutes a year.
  4. In practice "independent" is optimistic: a bad global config push or a deploy to both regions at once fails them together. That is why multi-region systems deploy region by region, and why the routing layer itself is built from redundant, anycast-style infrastructure.

#Drawing and narrating the design

Added

The whiteboard (or the shared drawing tool, in a remote interview) is the one artefact you and the interviewer build together. A good drawing makes the design checkable: the interviewer can point at any arrow and ask "what flows here, and what happens if it fails?" A bad drawing is a cloud of boxes whose connections only exist in your head.

The mental model is a transit map. Stations are components; lines are the data flows; and you narrate by riding one journey at a time: "follow a photo from the user's phone to someone else's feed". Each journey is one functional requirement, so by the time every requirement has been ridden end to end, the design is complete, and the interviewer has heard the reasoning for every station on the way.

What to draw first

Draw in the order a request travels, so the picture grows the way you talk:

  • The client on the left (phone, browser), because every journey starts there.
  • The entry point: a load balancer or API gateway. Name what it does once ("TLS, routing, rate limits") and move on.
  • The services for the core features, as few as the requirements need. One service per functional area is plenty at this stage; don't draw microservices you can't justify.
  • The data stores, each labelled with what it holds ("photo metadata", "image bytes"), not just "DB".
  • Then the extras the numbers asked for: a cache where reads are heavy, a CDN where egress is huge, a queue where work can be deferred.
  • Number the arrows and label each with what flows (a request name, an event), and mark async ones (a dashed line is a common convention). Numbers let both of you refer to "step 3" later.

Here is the high-level design for the photo-sharing app, drawn that way:

Client app · web Load balancer TLS · routing Upload service stateless Feed service stateless Object store image bytes Resize queue + workers Metadata DB users · posts · follows Cache feeds · hot posts CDN edge caches PUT image bytes with the presigned URL resized miss on a miss, fetch from the origin 1 2 3 3 4 4 5
(1) POST /photos returns a presigned URL; (2) the bytes go straight to object storage; (3) a metadata row and a resize job; (4) feed reads, cache first; (5) images from the CDN. Dashed lines are asynchronous; dotted ones happen only on a cache miss.

And here is how you would narrate the upload journey while drawing it, one sentence per step, each tied to a requirement or a number:

Narration: the upload journey
"Let me follow one photo from upload to someone's feed.

 (1) The app calls POST /photos with the caption. It goes through the
     load balancer to the upload service, which checks auth and returns
     a presigned URL for the object store, plus a new post id.
 (2) The app uploads the 2 MB of bytes straight to the object store with
     that URL. I'm keeping image bytes off our API servers on purpose:
     at peak that's almost 3 Gbit/s of uploads we don't have to proxy.
 (3) When the upload completes, the upload service writes the metadata
     row (post id, user id, caption, object key, time) to the database
     and puts a resize job on a queue. Workers make the smaller sizes
     asynchronously, so the user isn't waiting on that.

 That covers requirement 1. Do you want me to go into how the post gets
 into followers' feeds now, or finish the read path first?"
What’s happening
  1. The opening line announces the journey, so the interviewer knows what the next minute is about and can redirect early if they want something else.
  2. Each step names the component, what flows, and why: "presigned URL" is the mechanism, and "almost 3 Gbit/s we don't have to proxy" is the reason, taken straight from the estimate (2.78 Gbit/s peak ingress). That's how numbers get used, not just computed.
  3. Step 3 says out loud what is asynchronous and why (resizing doesn't block the user). It also quietly sets up a deep dive: what if a resize job fails? Interviewers often pick up threads like that.
  4. The narration ends by tying back to a requirement ("that covers requirement 1") and offering a choice of where to go next. That keeps the interviewer involved and shows you're managing the time.
  5. Notice what it doesn't do: no technology brand names yet ("object store", not a product), no detail on the resize workers. Depth comes later, where the interviewer wants it.
AdvancedThinking aloud without rambling
  • Say the options before the choice. "We could push new posts into followers' feeds when they're written, or build feeds when they're read. Writes are 5 M a day and reads 500 M, so I'll push at write time, except for accounts with millions of followers."
  • Tie every choice to a requirement or a number. If you can't, it may not belong on the board yet.
  • Mark assumptions as assumptions. "I'm assuming a follow graph of a few hundred per user on average; tell me if that's wrong."
  • Check in at phase boundaries, not every sentence. "That's the high-level design. Before deep dives, anything you'd like me to change?"
  • Keep the board tidy. Leave room to the right for things you'll add (cache, queue); write numbers in a corner table; erase dead ends instead of leaving them.
AdvancedHandling the interviewer's hints

Handling hints

Interviewers steer with questions. Most of them are hints, and each has a usual meaning:

They sayIt usually meansA good response
"What happens if this database goes down?"Your design has a single point of failure thereName the failure, then the fix: replica plus automatic failover; what's lost during the switch
"Is that fast enough?"Check it against the latency requirementAdd up the hops on the path (see Latency numbers to know) and say where the time goes
"How would this work for a user with 50 million followers?"There's a hot spot or fan-out problemQuantify it, then treat the outlier differently (for example pull instead of push for celebrities)
"Are you sure about that?"Reconsider; it may be wrongRe-check aloud; if it's wrong, say so and fix it. Defending an error costs more than the error
"Let's move on" or "that's fine"You've spent enough time hereStop, summarise in one sentence, move to the next phase
A long silenceThey're writing notes, or waiting for you to driveOffer a direction: "Shall I go deeper on X, or move to Y?"
"How would this handle 10× the traffic?"

The interviewer points at your finished photo-app diagram and asks how it would handle ten times the traffic. What's the best way to answer?

Show answer

Re-run the numbers, find which component breaks first, and fix that one, rather than listing generic scaling techniques.

What’s happening
  1. Multiply the estimates by 10: feed peak goes from 17.4 K to 174 K QPS, image egress peak from about 419 Gbit/s to about 4.2 Tbit/s, uploads from 174 to about 1,740 per second, metadata growth from 15 GB to 150 GB a day.
  2. Walk the diagram with those numbers. Stateless services scale by adding servers (the same arithmetic as capacity.mjs gives 435 feed servers instead of 45). The CDN scales by buying more of it. Neither is a design change.
  3. The parts that do change are stateful: about 1,740 metadata writes a second and 274 TB of metadata in five years (over 90 TB per copy) mean the database must be partitioned (for example by post id or user id), and the cache cluster grows accordingly.
  4. Say what you'd measure before building: per-server QPS from a load test, cache hit ratio, database write latency.
  5. This answer scores because it uses the estimates you already have, finds the first bottleneck rather than all of them, and treats stateless and stateful parts differently, which is the real lesson of scaling.

#Common mistakes

Added

Most candidates who struggle in system design interviews don't lack knowledge; they lose control of the process. They know caches and queues, but they jump to them before knowing what is needed, or spend the whole time on one part, or design in silence. The good news is that process mistakes are the easiest to fix, because you can practise the fix.

A useful mental model is the interviewer's report from the first topic: each mistake below either produces no evidence (silence, no numbers) or produces evidence against you (a design that can't be justified, an unfixed single point of failure). The fixes all work by turning that time back into positive evidence.

Jumping to technology

It sounds like: "I'd use Kafka, Cassandra and Redis" in the first two minutes. Technologies are answers; without requirements there's no question yet, so the interviewer can't tell whether you chose them or just know their names.

Weak

"For the feed I'll use Cassandra because it scales, Kafka for events, and Redis for caching."

Strong

"Feed reads are 500 M a day, 100 times the writes, and can be seconds stale. So the main problem is serving reads cheaply: precomputed feeds in a cache, rebuilt from a store we can read by user id quickly. A wide-column store such as Cassandra fits that access pattern; a relational database with read replicas would also work at this size."

The fix: requirement → property needed → component → (optionally) product. Name the product last, and name an alternative.

No numbers

Designing without estimates means you can't justify a cache, a CDN or sharding, and you can't spot the part that will break. The fix is the five-minute chain from Back-of-the-envelope estimation: QPS, peak, storage, bandwidth, written in a corner of the board and used at least twice later ("this is why…").

Over-engineering

Drawing twelve microservices, a service mesh, Kafka and three databases for a system with 50 requests a second. It reads as pattern-matching on big-company architectures, not as design. It also burns time explaining boxes the problem doesn't need.

The fix: start with the simplest design that meets the numbers, then add each component when a requirement or a number demands it, and say which one: "Up to here one database is fine. At 27 TB of metadata in five years, that's where we'd need to partition."

Ignoring failure

A design where every box is assumed to work forever. Interviewers will ask "what if this dies?" and a candidate who hasn't thought about it has to improvise. The fix: during the wrap-up (or earlier, unprompted), walk the diagram once looking for single points of failure, and for each say what happens when it fails, what the user sees, and the fix (redundancy across zones, failover, retries with idempotency, degraded mode). See Availability, SLAs, SLOs and SLIs for the arithmetic behind "redundant enough".

Silent drawing

Covered in Drawing and narrating the design: what you don't say isn't scored, and you can't be steered away from a dead end. The fix is to narrate journeys, say options before choices, and check in at phase boundaries.

Depth at the wrong time

Two opposite mistakes. Too early: spending 20 minutes on the perfect database schema before anything else is drawn, then running out of time with half a design. Never: a complete but shallow design where every box is a label and nothing is explained in depth, which caps you below senior. The fix is the time budget from A repeatable framework: a complete design by about the halfway point, then two deep dives.

Not driving, or not listening

Waiting for the interviewer to say what to do next reads as a lack of ownership, especially at senior level. The opposite, ignoring the interviewer's hints because you planned to talk about something else, reads as poor collaboration. The fix: you propose, they dispose. Offer the next step ("I'd like to go deep on feed generation next"), and when they redirect, follow.

Defending a mistake

When you realise (or are told) that something doesn't work, the strongest move is to say so plainly and fix it: "You're right, that's a single writer for every like on a viral post; let me shard the counter." Interviewers see mistakes in every interview. Recovering well is positive evidence; defending an error is negative evidence.

Spot the mistakes

A candidate's first five minutes, summarised: "This is a URL shortener, so I'll use microservices: a shortening service, a redirect service, an analytics service and a user service, with Kafka between them and Cassandra for storage. I'll draw it now." Then six minutes of silent drawing. Which mistakes do you see, and what should the first five minutes have looked like?

Show answer

At least four: no requirements, no numbers, technology before need (over-engineering), and silent drawing.

What’s happening
  1. No requirements: we don't know whether analytics or user accounts are even in scope, whether short links expire, or whether custom aliases are needed. Two of the four services may not be needed.
  2. No numbers: a URL shortener's defining number is the read/write ratio (redirects vastly outnumber creations) and the total links stored. Without them Kafka and Cassandra are unjustified.
  3. Over-engineering: four microservices and a log for what may be two endpoints (POST /links and GET /:code) and one table. The simplest design that meets the numbers should come first.
  4. Silent drawing: six minutes with no evidence and no chance for the interviewer to redirect.
  5. Better first five minutes: confirm the core features (create a short link, redirect, maybe expiry), ask scale ("100 M new links a month? redirects per link?"), state latency and availability for redirects (they're on users' critical path), write it down, then estimate. The design follows from that, and every box gets a reason.

Written fresh for interviews (there was no Google Doc for this one). Runnable models were run on Node 26; figures and limits were checked against sources in October 2026.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.