ealeng

Episode 01 of 1 · One box, measured

Back-of-the-envelope estimates, then measure

How many servers does a URL shortener need? The whiteboard estimate said 11; measuring one server said 2. How to write an estimate you can check (load math plus a latency budget of named parts), how to measure with percentiles you can trust, where the time really went, and how Little's law caught a load test that made an idle server look overloaded.

Lab 01 · Back-of-the-Envelope, Then Measure14 min read
Video transcript12:45

0:00Cold open

0:01

I put our new service under load. Four hundred and fifty requests a second, and the slowest one in a hundred took over a second.

0:09

It looked like the server was falling over.

0:13

But the server was barely working. It used a sixth of one processor core.

0:19

And before any of this, the whiteboard said we’d need eleven servers. Measuring said two.

0:26

Today: where all of these numbers came from, and which ones to trust.

0:31Intro

0:32

Hands-on system design, part one.

0:35

Today: estimate first, then measure, and find out why you were wrong.

0:40Meet Lucía

0:42

I’m Lucía, a platform engineer. We’re building a URL shortener: short links that send you on to long ones.

0:49

My manager asked one question: how many servers will it need?

0:54

Step one is an estimate, on the back of an envelope: rough math with known numbers, before you build anything.

1:02

Step two is to measure, and find out how wrong the estimate was, and why.

1:07

To measure, I built a small test service on my laptop.

1:11

A client sends requests. A Python web app answers them. Behind it, a Postgres database holds a million rows.

1:20

Docker runs the app and the database side by side, each in its own container.

1:26

Each request works like opening a short link: find one row by its id, and send it back.

1:33

One caveat: this is one laptop, so my numbers are for my laptop. The method is what carries over.

1:4101 · What you need

1:43

What do you need to build this bench yourself?

1:46

Python, in a virtual environment, with a few packages.

1:51

Docker. One command starts the app and the database.

1:55

A setup script loads a million rows into the database. It takes a few seconds.

2:01

It also saves a fingerprint of the data, so you can check your data matches mine, row for row.

2:09

No cloud, and no API keys. Everything runs on your own machine, for free.

2:1602 · The envelope

2:17

First, the estimate. Ten million people use the shortener each day, and each one clicks about twenty links.

2:25

That’s two hundred million clicks a day.

2:28

A day has 86,400 seconds. So that’s about 2,300 clicks a second, on average.

2:36

But traffic isn’t flat. Say the busiest hour gets about three times the average. With new links being made too, that’s about seven thousand requests a second.

2:47

How many can one server handle? The usual whiteboard guess is a thousand a second.

2:54

Keep thirty percent of each server spare, for safety. That leaves seven hundred.

2:59

Seven thousand divided by seven hundred is just over ten. Round up: eleven servers.

3:0803 · The math, in code

3:09

Here’s the same math, in Python. My script keeps it in two small functions.

3:15

Clicks a second: daily users, times clicks per user, divided by the seconds in a day.

3:22

The peak: that, times the peak factor.

3:25

What one server can take: what it handles, minus the thirty percent spare.

3:31

And the servers: the peak divided by that, rounded up.

3:36

I run it, and it prints the whole estimate.

3:40

The last line says eleven servers. But look at what it rests on: a guess of a thousand requests a second.

3:4804 · Which guess matters?

3:50

Every input in that estimate is a guess. So which guess matters most?

3:55

I moved each one across a range you could defend in a meeting, and kept the others the same.

4:01

Daily users, from five to twenty million: six to twenty-one servers.

4:07

Clicks per user, and the busiest hour: they swing it even more.

4:12

Which guess do you think swings it most?

4:17

How much one server can handle. From three hundred to five thousand a second, the answer goes from thirty-four servers down to three.

4:27

And unlike the user count, nobody can hand it to you for your own service. You have to measure it.

4:3405 · A latency budget

4:36

Before measuring, I also wrote down how long one lookup should take.

4:41

Not as one number, but as a budget: named parts that add up.

4:46

The trip from the client to the app and back: half a millisecond. That’s the classic textbook number for a round trip inside a data center.

4:56

The web framework: a tenth of a millisecond. That’s a guess, and it’s labelled as one.

5:02

The app’s trip to the database and back: another half a millisecond.

5:07

The query: about fifty microseconds of overhead, and almost nothing for the lookup itself.

5:14

In total: about one point one five milliseconds.

5:19

Why parts? If the total turns out wrong, the parts show which line was wrong.

5:25

And I saved it before measuring. A prediction written after the result isn’t a prediction.

5:3206 · Measure

5:34

Now, measure. My client sent twenty thousand lookups, one at a time, after a short warm-up.

5:41

Each bar counts the requests that took that long.

5:45

Not every request takes the same time. Most land near zero point six milliseconds. A few take much longer.

5:53

So we describe them with percentiles. P fifty, the median: half were faster, and half slower. Zero point five eight milliseconds.

6:03

P ninety-nine: ninety-nine in a hundred were faster. Zero point eight nine.

6:10

Everything in red is the slowest one in a hundred. An average would hide them, and users notice them.

6:17

Percentiles need lots of requests. To pin down a p99, you want about a thousand, or more.

6:2607 · Predicted vs. measured

6:28

So, the lookup. Predicted: one point one five milliseconds.

6:33

Measured: zero point five eight. Twice as fast as my budget.

6:38

The other requests I tested, like a write, and a slow scan of the whole table, were faster too: by one point four to two point five times.

6:49

So, was my budget good?

6:53

For choosing what to build, yes. It got the size right.

6:58

But a total can be close while its parts are badly wrong. Let’s open one up.

7:0408 · Server-Timing

7:06

How do you see inside one request? My app adds a small header to every response, called Server-Timing.

7:13

It reports two numbers: the time spent waiting on the database, and the time spent in the whole app.

7:20

The client knows its own total. Take away the app’s time, and the rest happened outside my app’s code: the network, Docker, the client, and the web server reading the request.

7:3309 · Where the time goes

7:35

Here’s one lookup, split in three. On top, my budget. Below, what I measured.

7:41

Outside the app: predicted half a millisecond, measured zero point three seven. Close, but by luck: there’s no data center network here.

7:51

The app code: zero point zero five. Half my guess.

7:56

The database: predicted zero point five five, measured zero point one six. Off by three and a half times.

8:05

Why? I charged for a trip between two machines in a data center. Here, the app and the database sit side by side, on one laptop.

8:15

Inside the database, the query itself took eight microseconds. Even an empty query, timed from my laptop, takes about a hundred and thirty-six, just to go there and back.

8:28

So the database time was almost all the trip and the driver, not the query. A faster query wouldn’t help.

8:35

And inside that outside part was something my budget never named: the client library, the code my client uses to send requests. About zero point two two milliseconds, more than the app code.

8:49

The total was off by two times. One part was off by three and a half, one was close by luck, and one wasn’t named at all. Only the parts show that.

9:0110 · One server’s limit

9:02

Back to the big guess. How many requests can one server handle?

9:07

I ran many clients at once. Each one sends its next request as soon as the last one comes back.

9:15

One client: about a thousand requests a second.

9:19

Sixteen clients: about six thousand.

9:23

Sixty-four clients: six thousand eight hundred. The line levels off.

9:29

Why does it level off? The app is one Python process, and it runs its Python code on only one core at a time. Here, it’s using all of that core.

9:40

More clients just wait in line. The slowest one in a hundred went from under two milliseconds to eighteen.

9:47

And the whiteboard guess, a thousand a second? One server did almost seven times that.

9:5511 · Little’s law

9:56

Can I trust these numbers? There’s a free check, called Little’s law.

10:01

Think of a coffee shop. Ten customers arrive each minute, and each one stays five minutes. How many are inside? Fifty.

10:10

Requests work the same way. The number in flight, L, equals arrivals a second, lambda, times the time each one spends inside, W.

10:20

At sixty-four clients: six thousand seven hundred and seventy-seven a second, times nine point four milliseconds. That’s almost exactly sixty-four. It checks out.

10:33

It works for any part of the system, too. The app counts its own requests: thirty-eight and a half of the sixty-four were inside it, on average.

10:42

And the arrival rate times the app’s own time per request gives thirty-eight and a half too: two different instruments, one answer. So the other twenty-five and a half were waiting outside the app.

10:5612 · The collapse that wasn’t

10:57

Now, the test from the start. The same sixty-four clients, and the same server. But all of them sent from one Python process.

11:06

Four hundred and forty-nine requests a second. The slowest in a hundred: one point two seconds.

11:14

Is the server overloaded?

11:19

The app used sixteen percent of its one core. It was mostly idle.

11:24

Little’s law shows where the requests were: sixty-three point eight of the sixty-four were outside the app.

11:32

Now, with eight client processes sending: six thousand seven hundred a second. The same server.

11:39

So the requests must have been stuck in my load generator, the client. One Python process, juggling sixty-four connections, couldn’t keep up.

11:49

Before you believe a server’s limit, check the tool that’s pushing it.

11:55Back to Lucía

11:57

So, how many servers? The whiteboard said eleven.

12:01

Measured: two. One server does about six thousand eight hundred a second.

12:07

But that’s the very top of the curve, where requests already wait in line. And it’s my laptop, not a production server.

12:15

Next time: why a load test like this one can hide a one-second freeze.

12:21

For today: write the estimate down first, as named parts. Then measure. And check your tools before you trust the result.Lucía: Estimate. Then measure.

12:31Summary

12:32

A budget of named parts, percentiles instead of averages, a measured number for one server, Little’s law as a free check, and a load generator you’ve checked.

Key takeaways

  • ✓Load estimate: 10M daily users × 20 clicks ÷ 86,400 s ≈ 2,300 a second, about 7,000 at a 3× peak with new links. At the whiteboard's 1,000 requests a second per server, keeping 30% spare, that's 11 servers.
  • ✓Which guess matters? Over ranges you could defend in a meeting, requests per server (300–5,000) moved the answer 11×, from 34 servers to 3, more than users (3.5×), clicks (8.3×) or the peak (4.9×). It's also the one input nobody can hand you: you have to measure it.
  • ✓Write the latency estimate as a budget of named parts, saved before measuring. The lookup was predicted at 1.15 ms and measured at 0.58 ms (p50). The total was 2× off, but the database part was 3.5× off, one part was close by luck, and the client library (0.22 ms, more than the app code) wasn't in the budget at all.
  • ✓Report percentiles, and know how many samples each rests on. p99 from 20,000 lookups was 0.89 ms (0.88–0.91). p99.9 from 500 requests has 0.5 expected samples above it: it's the max with a fancy name. To pin a p99 down you want about 1,000 requests; a p99.9, about 10,000.
  • ✓One Python server did about 6,800 lookups a second at the top of the curve, not 1,000, so the shortener needs 2 servers, not 11. But that's where p99 has grown from 1.7 ms to 18 ms: the most it can do, not what it does well.
  • ✓Little's law (L = λ × W) is a free check on the measurement. With the same 64 requests in flight sent from one client process, the server looked 15× slower with a 1.2-second p99 while the app used 16% of its core: 63.8 of the 64 requests were waiting in the client. Check the load generator before you believe a server's limit.

A back-of-the-envelope estimate is rough math from known numbers, done before you build anything: how many requests a second, how much storage, how many servers. It's how most system design starts, and it's rarely checked. This chapter checks one. The short version: the estimate said 11 servers and measuring said 2, and every number on the way, including a load test that made an idle server look overloaded, had to be checked before it could be trusted.

The examples come from Lab 01 (Back-of-the-Envelope, Then Measure) of the system design labs: scripts 01_estimate.py (the estimate), 02_percentiles.py (checking the percentile code), 03_measure.py (measuring one request at a time), 04_where_time_goes.py (splitting the time into parts) and 05_littles_law.py (load and Little's law). The service is small and runs on one laptop (an Apple M4 Max): a Python web app (FastAPI, one worker) in Docker, with Postgres 18 holding a million rows, and a client on the same machine sending requests. So the numbers are this laptop's. The method is what carries over.

The envelope

The product is a URL shortener: short links that send you on to long ones. Opening a short link is one read (find the row by its id), and making a new link is one write. The inputs are typical interview numbers, not data:

  • 10M daily users, each clicking 20 short links and making 0.2 new ones a day.
  • A 3× peak: the busiest hour gets three times the daily average.
  • 1,000 requests a second per server, the usual whiteboard guess, keeping 30% of each server spare.
clicks a second  = 10M × 20 ÷ 86,400 s          ≈ 2,315
at peak          = (clicks + new links) × 3     ≈ 7,014
usable per server = 1,000 × (1 − 0.3)            = 700
servers          = 7,014 ÷ 700 = 10.02 → round up = 11

The same script also gives 69 writes a second at peak, 1.09 TB a year of new data with 3 copies, and 28 Mbit/s going out. All small. The last line, 11 servers, is the one everybody asks about, and it rests on a guess.

Which guess matters?

Every input is a guess, so the lab moved each one across a range you could defend in a meeting, keeping the others at their defaults:

Input Range Servers Spread
Daily users 5–20M 6–21 3.5×
Clicks per user 5–50 3–25 8.3×
Peak factor 2–10× 7–34 4.9×
Requests a second per server 300–5,000 34–3 11×

What one server handles moves the answer most. And it's different in kind: the business can give you the user count, and traffic history can give you the peak, but nobody can hand you requests per server for your service. You have to measure it. The peak factor comes second in risk for a similar reason: it depends on time zones, events and launches, not on anything you control.

Try it · The envelope, and which guess moves itLab 01 · 01_estimate.py · 30% of each server kept spare

Daily users

Clicks per user per day

Busiest hour ÷ average

Requests a second one server handles

Clicks a second
2,315
At peak (+ writes)
7,014
Usable per server
700
Servers
11

7,014 ÷ (1,000 × 0.7) = 10.0 → 11 servers

Each guess at its low and high end, the others at the default

Daily users6–21 · 3.5×
Clicks per user per day3–25 · 8.3×
Busiest hour ÷ average7–34 · 4.9×
Requests a second one server handles3–34 · 11.3×

Ranges: 5–20M users, 5–50 clicks, a 2–10× peak, 300–5,000 requests a second per server. The input that moves the answer most is the one nobody can hand you: what one server does. Pick “6,777 measured” to use the number this chapter measures.

A latency budget, saved first

The second estimate is how long one request should take. The lab writes it as a budget: named parts that add up, each a reference number (the rounded "latency numbers every programmer should know") or a guess labelled as one. For the lookup, GET /k/{id}:

Part Predicted Where it comes from
Client ↔ app round trip 0.5 ms a round trip inside a datacenter
Framework (HTTP + JSON) 0.1 ms a guess, labelled
App ↔ database round trip 0.5 ms one query, one round trip
Query overhead 50 µs a guess, labelled
B-tree lookup + row fetch 0.5 µs ~5 memory reads at 100 ns
Total 1.15 ms

Two habits matter more than the numbers. Parts, not one number: if the total turns out wrong, the parts show which line was wrong, or missing. And saved before measuring: the script writes the budgets to data/predictions.json with a timestamp and won't overwrite it without --force. A prediction written after the result isn't a prediction.

Measure, with percentiles you can trust

Requests don't all take the same time, so you describe them with percentiles. p50 (the median): half were faster. p99: 99 in 100 were faster, so it describes the slowest 1 in 100. An average hides those slow ones, and users notice them.

Before trusting a percentile, the lab checked the code that computes it, on simulated latencies where the true answer is known. Its percentile function matches numpy's default (linear) exactly. Then it checked two ways of putting a 95% interval around a percentile, by counting how often each covered the truth over 300 simulated samples:

Percentile Requests Expected samples above it Bootstrap covers Order-statistic covers
p50 500 250 93% 94%
p99 500 5 91% 99%
p99 20,000 200 93% 94%
p99.9 500 0.5 43% 99% (no upper bound)
p99.9 5,000 5 90% 99%

The bootstrap (resample your data, recompute the percentile many times) can never imagine a value bigger than your slowest request, so in the tail it's too confident: for p99.9 from 500 requests, its "95%" interval missed the truth more than half the time. The order-statistic interval uses the fact that the count of samples below the true percentile follows a binomial distribution, and when there are too few samples it says so: no upper bound. That's the one the lab reports. (The lab's first version of it clipped the upper end to the slowest request, which looked tidy and also covered only 41% of the time. Checking the checker caught it.)

The rule underneath: a percentile is only as good as the number of samples beyond it. An interval has an upper end only once about 3.7 samples are expected above the percentile: about 370 requests for p99, about 3,700 for p99.9. To pin one down, you want 10 or more beyond it: about 1,000 requests for p99, about 10,000 for p99.9.

Then the lab measured each endpoint, one request at a time over one kept-alive connection, after 200 warm-up requests it threw away:

Endpoint Requests p50 p99 (95% interval) p99.9 (95% interval) Predicted
GET /k/{id} (lookup) 20,000 0.58 ms 0.89 (0.88–0.91) 1.58 (1.34–1.80) 1.15 ms
POST /k (write) 5,000 0.86 ms 1.39 (1.34–1.48) 6.33 (2.37–12.5) 2.15 ms
GET /slow (full scan) 500 72.1 ms 78.7 (76.6–98.2) 95.1 (87.0–∞) 101 ms

Every endpoint was faster than predicted, by 1.4–2.5×. The lookup was twice as fast as its budget. For choosing what to build, that's a good estimate: the right size. But a total can be close while its parts are badly wrong.

Where the time went

To see inside a request, the app adds a Server-Timing header to every response with two numbers: the time spent waiting on the database, and the time spent in the whole app. The client knows its own total, so subtracting splits each request in three: outside the app (the client, the network, Docker, the web server reading the request), app code, and database. For the lookup's p50:

Part Predicted Measured
Outside the app 0.50 ms 0.37 ms close, by luck: there's no datacenter network here
App code 0.10 ms 0.05 ms half the guess
Database 0.55 ms 0.16 ms 3.5× too high
Total 1.15 ms 0.58 ms 2×
  • The database part was all trip. The budget charged a datacenter round trip for a hop between two containers on one laptop. Inside Postgres, the query ran in 8 µs; even an empty query, timed from the laptop, takes about 136 µs to go there and back. A faster query wouldn't help. Fewer trips, or a cache, would.
  • A part nobody named. Timed separately with a raw socket, the client library (httpx) cost about 0.22 ms, more than the app code, and Docker Desktop's port forwarder about 0.11 ms. Neither was in the budget.
  • The write missed two round trips. A write is three trips to Postgres (BEGIN, INSERT, COMMIT), not one. The budget's 1 ms flush to disk turned out to cost about 80 µs in Docker's VM, but a flush forced through to the Mac's own storage took 4.0 ms, so the VM's flush most likely stops at a cache. Write latency on a laptop VM says little about durable writes on a server.
  • The full scan was right for the wrong reason. 100 ns a row predicted, 77 ns measured on one core. But EXPLAIN ANALYZE in psql ran the same query in 29 ms, using extra parallel workers. The app's call (asyncpg's fetchval()) asks Postgres for at most one row, and Postgres doesn't run a query in parallel when the request has a row limit. The plan you test in psql isn't always the plan your app gets.
Try it · The budget, part by partLab 01 · predictions saved before measuring · p50, Server-Timing

primary-key lookup, 1M rows

Predicted1.15 ms
Measured0.58 ms
Outside the app App code Database
PartPredictedMeasuredOff by
Outside the app0.500.371.4× high
App code0.100.051.9× high
Database0.550.163.5× high
Total1.150.582.0× high

The database part was off by 3.5×: the budget charged a datacenter round trip (0.5 ms) for a hop inside one VM. The query itself ran in 8 µs; the trip around it took 136 µs.

Predicted parts use only reference numbers (a 0.5 ms datacenter round trip, a 1 ms flush, 100 ns a memory read) and two labelled guesses (0.1 ms of framework, 50 µs of query overhead). Measured: median of 5,000–20,000 requests sent one at a time (500 for /slow).

One server's limit

Back to the guess that moved the answer most: how many requests one server handles. The lab ran C clients at once against the lookup, each sending its next request as soon as the last one came back, for 8 seconds per level, from up to 8 client processes:

Clients Requests a second p50 p99 App CPU (cores)
1 1,058 0.86 ms 1.68 ms 0.27
4 3,028 1.25 ms 2.50 ms 0.51
16 6,074 2.66 ms 4.24 ms 0.91
32 6,347 4.91 ms 9.90 ms 0.99
64 6,777 9.24 ms 17.8 ms 0.99

The line levels off once the app, one Python process, uses all of its one core. After that, more clients only wait in line: p99 went from under 2 ms to 18. One server did about 6,800 a second, almost 7× the whiteboard's 1,000, and at that rate the shortener needs 2 servers, not 11.

That's the very top of the curve, though, where requests are already queueing. Sizing should use the rate at which p99 still meets your target, plus room for failures and deploys (and at least 3 servers for redundancy). It's also one Python worker on a laptop, reading by primary key with no cache.

Little's law, a free check

Can you trust those numbers? Little's law says that for any stable system, the number of requests in flight equals the arrival rate times the time each spends inside:

L = λ × W

A coffee shop where 10 customers arrive a minute and each stays 5 minutes has 50 inside. At 64 clients: 6,777 requests a second × 9.42 ms ≈ 64 in flight. It checks out, and it held to 0.3% at every level. It also holds for any part of the system: the app counted its own in-flight requests and got 38.5 of the 64, and the arrival rate times the app's own time per request also gave 38.5. Two instruments, one answer, so the other 25.5 were waiting outside the app.

Two uses, then: a check (measure L, λ and W separately; if they disagree, an instrument is wrong) and a locator (subtract to see where requests are waiting). The check caught a bug while the lab was built: the app's in-flight counter came out at −2.84 after a reset, and λ × W disagreed at once.

The collapse that wasn't

Same server, same 64 requests in flight, but sent from fewer client processes:

Client processes Requests a second p99 App CPU Outside the app
1 449 1,238 ms 16% 63.8 of 64
2 1,138 336 ms 28% 63.6
4 2,466 129 ms 49% 63.2
8 6,720 17.2 ms 99% 25.4

From one process, the server looked 15× slower with a 1.2-second p99, as if it were falling over. But the app used a sixth of its core, and Little's law showed where the requests were: 63.8 of the 64 were outside the app. One Python process juggling 64 connections couldn't keep up, so the requests were stuck in the load generator. Before you believe a server's limit, check the tool that's pushing it. (The lab hit this for real at first, with 4 client processes, before it became an exercise.)

Try it · One server's limit, checkedLab 01 · 05_littles_law.py · GET /k/{id}, one Python worker

Clients sending at once (C)

Requests a second
6,777
p99
17.8 ms
App CPU
99%
Inside the app
38.5/64
In the app (38.5, the app's own count) Outside it (25.5)

Little's law: λ × W = 6,777/s × 9.43 ms = 63.9 in flight (C = 64)

Each client sends its next request as soon as the last returns, for 8 s per level, from up to 8 client processes. Throughput levels off near 6,800 a second once the single Python worker uses its one core; after that, more clients only wait. The app counts its own in-flight requests, a second instrument that agrees with λ × its own time per request.

Caveats

  • One laptop. Client, app and database share one machine's cores, through Docker Desktop's VM and port forwarder. Every number here is this laptop's, and runs drift: the 5,000-request p99 for the lookup (0.84 ms, 0.82–0.87) doesn't overlap the 20,000-request one (0.89 ms, 0.88–0.91).
  • One request at a time is not real traffic. These measurements are closed-loop: the client waits for each reply, so when the server stalls, the client stalls too and never sends the requests users would have sent. That hides stalls; the next lab measures how much.
  • The 6,800 is a flat top, for one Python worker doing primary-key reads with no cache. Size from a point left of the knee, not the top.
  • The laptop's flush isn't a server's. About 80 µs in the VM against 4.0 ms forced through to the Mac's storage.
  • The interval numbers are simulated. The coverage table uses simulated latencies with a known answer, 300 samples per row, ±3 points.

The checklist

  1. Write the load estimate down, from product numbers to requests a second at peak, then servers.
  2. Find the input that moves it most, and measure that one. Usually it's what one server does.
  3. Write the latency estimate as named parts, each a reference number or a labelled guess, and save it before measuring.
  4. Report percentiles with intervals, and count the samples beyond each: 1,000+ requests for a p99, 10,000+ for a p99.9.
  5. Split measured time into the same parts (Server-Timing, a raw-socket client, EXPLAIN ANALYZE) and see which line was wrong or missing.
  6. Check L = λ × W with separate instruments, and use it to find where requests wait.
  7. Check the load generator (its CPU, and where the in-flight requests are) before you believe a server's limit.

Practice: estimate, then check

Write the lab's arithmetic: requests a second at peak from product numbers, servers with 30% kept spare, a percentile computed numpy's default way, the expected samples above a percentile and the smallest sample with a bounded interval, and Little's law. The tests use the recorded numbers: 7,014 at peak, 11 servers at 1,000 a second and 2 at 6,777, 368 requests before a p99 has an upper bound, and 6,777 a second × 9.42 ms ≈ 64 in flight, 38.5 of them inside the app.

Try it · Practice in PythonRuns in your browser · ⌘/Ctrl + Enter to run
estimate_practice.py55 lines

Quiz

Try it · Check your understanding0 of 6 answered
  1. 1.10M daily users each click 20 short links a day. About how many clicks a second is that, on average?

  2. 2.Which input moved the shortener's server count the most over its plausible range?

  3. 3.Why write a latency estimate as named parts, not one number?

  4. 4.A dashboard shows p99.9 from a 5-minute window of 800 requests. What is it really showing?

  5. 5.One server's throughput levels off at about 6,800 a second as clients are added. Why?

  6. 6.With 64 clients in one Python process, throughput fell to 449 a second and p99 to 1.2 s, while the app used 16% of its core. What does Little's law show?

Check your understanding

Try each question before opening its answer.

10M daily users each make 20 reads a day. How many reads a second at peak, and which input would you question first?

10M × 20 ÷ 86,400 ≈ 2,300 a second on average, about 7,000 at a 3× peak. Question the peak factor (it depends on time zones, events and launches; over 2–10× it moved the server count about 5×), then what one server handles, which only measuring can tell you: over 300–5,000 a second it moved the answer 11×.

Your latency estimate was off by 2×. Why might that hide much bigger errors?

A total is a sum of parts, and errors in the parts can cancel. The lookup's database part was 3.5× too high, the query 16× too low (0.5 µs predicted, 8 µs measured), the client library wasn't in the budget, and the write budget missed two of its three round trips. Write the estimate as named parts and measure each one.

Why report p99 and not the mean? And why isn't p99.9 from 500 requests worth reporting?

The mean hides the slow requests, and users notice those. But a percentile is only as good as the samples beyond it: 500 requests have 0.5 expected samples above p99.9, so it's just the slowest request. p99.9 needs about 3,700 requests for any upper bound and about 10,000 to mean much.

When does a bootstrap interval for a percentile mislead you?

In the tail, with few samples beyond the percentile. Resampling can never produce a value bigger than the slowest one observed, so it's too confident: for p99.9 from 500 requests it covered the truth 43% of the time instead of 95%. An order-statistic interval stays honest by reporting no upper bound.

State Little's law, and give two ways to use it on a load test.

Requests in flight = arrival rate × time inside, L = λ × W, for any stable system or part of one. As a check: measure L, λ and W separately; if they disagree, an instrument is wrong (it caught a negative in-flight counter). As a locator: of 64 requests in flight, the app held 38.5, so 25.5 were waiting outside it.

A load test shows throughput falling as you add clients. What do you check before blaming the server?

The load generator: its CPU, and where the in-flight requests are. From one client process, the same server did 449 a second with a 1.2-second p99 while the app used 16% of its core, because 63.8 of the 64 requests were waiting in the client. From 8 processes it did 6,720.

How many servers does the shortener need, and what's wrong with sizing from 6,800 a second?

At 6,777 a second with 30% spare, about 7,000 at peak needs 2, against 11 at the whiteboard's 1,000. But 6,800 is the top of the curve, where p99 had grown from 1.7 ms to 18 ms. Size from the rate at which p99 still meets your target, keep at least 3 for failures and deploys, and remember this was one Python worker on a laptop.

Next up: Load testing and tail latency (coming soon): every measurement here waited for each reply, so a one-second freeze on the server also froze the client. The next chapter builds a load generator that keeps sending, and measures how much a test like this one hides.

Learning something useful?

New labs, articles, and videos on evaluating and testing AI systems come out regularly. Follow along so you don't miss the next one.