System design · The method — requirements, arithmetic, storage, caching

Back-of-the-envelope estimation

The eight minutes of arithmetic that make every later decision decidable — QPS from one anchor number, storage over five years, and the single question all of it exists to answer: does the hot set fit in memory?

18 min read Free to read Patterns: one-anchor-then-derive, does-it-fit-in-memory

What you'll be able to do

Before this: what-the-round-measures

Estimation has a reputation as the ritual part of the round — the bit you get through so you can start drawing. That reputation is wrong, and believing it is what makes the rest of the round undecidable.

Consider two claims about the same design: "we'll need a cache" and "the hot set is 39 GB, so it fits on a single 64 GB cache node with room to spare, and at a 99% hit rate the database sees about 460 reads a second — which is the same order as the write traffic, so one primary handles both." The first is a preference. The second is a design that can be checked, and it was produced by two multiplications.

WatchExponent · 07:10

Two chapters from the overview cued in the first lesson, isolated because this is the moment the round either gets a scale or does not. Note the order — requirements first, then the estimate. An estimate produced before the non-functional requirements are agreed is an estimate of nothing.

Jump to the part you need

One anchor, then derive

Ask for exactly one number. Everything else you derive out loud, stating each assumption as you use it. Asking for six numbers reads as stalling; deriving six numbers from one reads as fluency.

The anchor is almost always daily active users or writes per day. From it:

JavaScript
// Every estimate starts from ONE number you asked for, and derives the rest.
const DAU = 100e6;             // 100 M daily active users
const READS_PER_USER = 20;     // feed opens per user per day
const WRITES_PER_USER = 0.2;   // posts per user per day  (1 in 5 users posts)
const SECONDS_PER_DAY = 86_400; // ~10^5 — the only constant worth memorising

const perSecond = (perDay) => perDay / SECONDS_PER_DAY;
const readQPS = perSecond(DAU * READS_PER_USER);
const writeQPS = perSecond(DAU * WRITES_PER_USER);

const fmt = (n) => Math.round(n).toLocaleString("en-US");
console.log("avg reads /s :", fmt(readQPS));
console.log("avg writes/s :", fmt(writeQPS));
console.log("read:write   :", Math.round(readQPS / writeQPS) + ":1");

// Traffic is never flat. 2x is the smallest defensible peak factor.
console.log("peak reads /s:", fmt(readQPS * 2));
console.log("peak writes/s:", fmt(writeQPS * 2));

Change READS_PER_USER to 5 and watch the ratio collapse to 25:1. That sensitivity is the point: the ratio is the most consequential number in the whole estimate, and it comes entirely from an assumption you invented. So say it out loud as an assumption — "I'm assuming twenty feed opens a day per active user; if it's five, the read:write ratio drops to twenty-five to one and the cache matters less" — and the interviewer will either accept it or correct it. Both outcomes are good. Silently assuming it is the only bad outcome.

Peak factor, and the two ways it bites

Average QPS is not what you provision for. Two multipliers sit between the average and the number your capacity plan needs:

  • Diurnal peak. A consumer product's busiest hour typically runs somewhere around 2–3× its daily average. Use 2× as a floor and say you would want real traffic data.
  • Burst. Push notifications, a televised event, a viral post. This is not a smooth multiplier; it is a spike measured in seconds, and it is why the design needs a queue somewhere rather than more machines.

Those two want different answers. Diurnal peak is a provisioning question — buy more capacity. Burst is a shape question — absorb it, shed it, or degrade. Saying which of the two you are designing for is a distinction most candidates never draw, and it costs one sentence: "I'll provision for 2× the average and handle bursts by making the write path asynchronous, so a spike lands in the queue instead of on the database."

Storage: the number that decides your datastore

Storage estimates go wrong in one predictable way — people estimate the text and forget that one image is a thousand posts.

JavaScript
const KB = 1024, GB = KB ** 3, TB = KB ** 4;

const POSTS_PER_DAY = 100e6 * 0.2;   // 20 M, from the previous cell
const BYTES_PER_POST = 300;          // text, ids, timestamps — no media
const MEDIA_FRACTION = 0.1;          // 1 post in 10 carries an image
const BYTES_PER_IMAGE = 300 * KB;

const textPerDay = POSTS_PER_DAY * BYTES_PER_POST;
const mediaPerDay = POSTS_PER_DAY * MEDIA_FRACTION * BYTES_PER_IMAGE;

console.log("text /day :", (textPerDay / GB).toFixed(1), "GB");
console.log("media/day :", (mediaPerDay / GB).toFixed(1), "GB");
console.log("media is  :", Math.round(mediaPerDay / textPerDay) + "x the text");

const REPLICAS = 3;
const YEARS = 5;
const raw5y = (textPerDay + mediaPerDay) * 365 * YEARS;
console.log("5y raw    :", (raw5y / TB).toFixed(0), "TB");
console.log("5y x3 repl:", (raw5y * REPLICAS / TB).toFixed(0), "TB");
console.log("text only :", (textPerDay * 365 * YEARS * REPLICAS / TB).toFixed(1), "TB");

Run it and read the last two lines together, because their ratio is the design.

Media, at one image on one post in ten, outweighs all the text by about a hundred to one. Five years of everything, replicated three times, is petabyte-scale. Five years of text only, replicated three times, is around 30 TB — which is a large database but an entirely ordinary one, the kind a sharded relational cluster handles without drama.

That is not a small observation. It is the argument for the single most common structural move in system design: put the blobs in object storage and keep only the metadata in the database. You did not need taste to reach it. You needed one division.

The question all of this exists to answer

Nearly every capacity estimate in an interview is really asking one question, and it is worth asking it explicitly, because the answer reshapes the whole design:

Does the working set fit in memory?

If yes, you have a cache-fronted design and the database is a durability layer. If no, you have a sharding problem and an eviction policy that matters. Nothing else in phase 4 is as consequential.

JavaScript
const GB = 1024 ** 3;

const POSTS_PER_DAY = 20e6;
const BYTES_PER_POST = 300;
const HOT_DAYS = 7;          // a week of posts covers almost every feed read

const hotBytes = POSTS_PER_DAY * HOT_DAYS * BYTES_PER_POST;
console.log("hot set        :", (hotBytes / GB).toFixed(1), "GB");

const NODE_RAM_GB = 64;      // an ordinary cache node
console.log("nodes @ 64 GB  :", Math.ceil(hotBytes / GB / NODE_RAM_GB));

// And what that buys on the read path.
const readQPS = 46_296;      // peak, from cell 1
for (const hitRate of [0.8, 0.95, 0.99]) {
  const misses = Math.round(readQPS * (1 - hitRate));
  console.log(`hit ${(hitRate * 100).toFixed(0)}%`.padEnd(8), "→ DB sees", misses.toLocaleString("en-US"), "reads/s");
}

Two results here are worth saying out loud in an interview.

A week of text fits on one machine. Thirty-nine gigabytes is not a distributed systems problem. It is a single cache node, and you should say so — along with the fact that you would run more than one anyway, for availability rather than for capacity. Knowing the difference between sharding because it doesn't fit and replicating because it must not die is a distinction that gets tested.

The hit rate is worth more than any other lever on the read path. Going from 80% to 99% cuts the database's read load twentyfold. At 99%, the database is absorbing roughly the same number of reads per second as it is writes — which means the read path has effectively stopped being the scaling problem, and if you spend your deep dive on read scaling you are spending it in the wrong place.

That last inference is the payoff for doing the arithmetic. It told you where the bottleneck is not, which is how you choose a deep dive with confidence.

Doing it in your head, out loud

You will do this on a whiteboard while someone watches, without a calculator, while also talking. The technique that survives those conditions is to round everything to one significant figure and track only the exponent.

One naming difference in this cell that is worth more than it looks: the JavaScript helper is called pow, and the Python tab calls it show_pow, because pow is a Python builtin. Python will let you shadow it without a word of complaint, and the code will work — right up until something further down the file wants the real pow. sum, min, max, list, dict, type, id, input, next and round are all the same trap, and sum in particular is a name people reach for constantly.

JavaScript
// Interview arithmetic done the way it survives being spoken aloud: round
// everything to one significant figure and track only the exponent.
const pow = (label, n) => console.log(label.padEnd(22), "10^" + Math.round(Math.log10(n)));

pow("seconds in a day", 86_400);       // 10^5
pow("seconds in a month", 2.6e6);      // 10^6
pow("100 M DAU", 100e6);               // 10^8
pow("20 reads/user/day", 20);          // 10^1

// 10^8 users x 10^1 reads / 10^5 seconds = 10^4 reads/s. Two multiplications,
// no calculator, and it lands inside a factor of 3 of the exact 23,148.
const exact = (100e6 * 20) / 86_400;
const rough = 10 ** (8 + 1 - 5);
console.log("rough:", rough.toLocaleString("en-US"), " exact:", Math.round(exact).toLocaleString("en-US"));
console.log("off by a factor of", (exact / rough).toFixed(1));

Ten thousand versus twenty-three thousand: off by a factor of about two, obtained by adding and subtracting exponents. That is accurate enough for every decision you will make in the next thirty minutes, because those decisions are all order-of-magnitude decisions. Does it fit on one machine, or a hundred? Do we need a cache, or not? No design in this round turns on the difference between 10,000 and 23,000.

Three constants are worth having memorised, and they are the only three:

QuantityRound valueWhy this one
Seconds in a day10⁵ (86,400)Converts every "per day" figure to QPS
Seconds in a month2.6 × 10⁶Monthly billing and retention windows
Bytes in a GB10⁹ (2³⁰)Storage, and close enough at one significant figure

The three estimates that are usually wrong

Some assumptions are load-bearing and unreliable at the same time. Flag these three explicitly rather than presenting them as facts, because an interviewer who has operated the real thing knows they are soft.

1. Requests per user per day. The number with the widest range and the largest influence on your read:write ratio. Anchor it to a behaviour instead of a number — "I'm assuming a user opens the feed about twenty times a day, which is a fairly engaged consumer app; a utility app would be closer to two."

2. Payload size. Fine for text, unreliable the moment media, embeddings, or denormalised fan-out rows are involved. If the payload is media, split the estimate in two as above; the split is more informative than either half.

3. Cache hit rate. Everybody writes down 80% and moves on. What actually determines it is the access distribution: a power-law distribution over a small hot set gives a very high hit rate at modest memory, and a uniform distribution over a huge key space gives a poor one no matter how much memory you buy. Say which one you think this workload is, and why. That sentence is the difference between quoting a number and understanding it.

What you should have on the board

After eight minutes, this is the whole artefact:

100 M DAU · 20 reads, 0.2 writes per user per day
  reads   ~23 K/s avg, ~46 K/s peak (2x)
  writes  ~230/s avg, ~460/s peak      → 100:1 read-heavy
storage   text 5.6 GB/day, media 570 GB/day  → media = 100x text
          5y x3 replicas ≈ 3 PB all-in, ≈ 30 TB text only
          ⇒ blobs to object storage, metadata in the DB
hot set   7 days of text ≈ 39 GB → fits one cache node
          at 99% hit, DB sees ~460 reads/s ≈ write load
          ⇒ reads are NOT the bottleneck; fan-out is
p99       feed read < 200 ms · redirect < 100 ms

Every line of that constrains something downstream, and the last line before the latency targets tells you where to spend your deep dive. You have not drawn a single box yet, and the design is already mostly determined — which is exactly the position you want to be in when you start drawing.

The next lesson takes the first of those forced decisions seriously: given these numbers, which datastore, and for what stated reason.