How I Measure a Server: The Knee, Percentiles That Lie, and Little's Law
I did not trust latency numbers until I could make a server slow down on purpose. This post is what came out of three small experiments in my system-design-challenges repo. Each one changed how I read a metric I reach for every day. The code is real, and the numbers below are what the scripts actually measured on this machine, not what I hoped they would be. Side note: this is the first challenge in a repo I keep for hands-on system design. It starts with the tools for measuring a service before it moves on to architecture, storage, and distributed systems. I work through it by running things, not just reading about them. The setup: one worker, about 10 ms of work per request The server is deliberately boring. Every request does roughly 10 ms of CPU-bound work in a spin loop that blocks the event loop, so the whole thing behaves like a single worker: a request that arrives while another is being served waits its turn. // server.js: a tiny server with a simulated servi
I did not trust latency numbers until I could make a server slow down on purpose. This post is what came out of three small experiments in my system-design-challenges repo. Each one changed how I read a metric I reach for every day. The code is real, and the numbers below are what the scripts actually measured on this machine, not what I hoped they would be.
Side note: this is the first challenge in a repo I keep for hands-on system design. It starts with the tools for measuring a service before it moves on to architecture, storage, and distributed systems. I work through it by running things, not just reading about them.
The setup: one worker, about 10 ms of work per request
The server is deliberately boring. Every request does roughly 10 ms of CPU-bound work in a spin loop that blocks the event loop, so the whole thing behaves like a single worker: a request that arrives while another is being served waits its turn.
// server.js: a tiny server with a simulated service time
const SERVICE_MS = Number(process.env.SERVICE_MS ?? 10);
const SLOW_CHANCE = Number(process.env.SLOW_CHANCE ?? 0);
const SLOW_MS = Number(process.env.SLOW_MS ?? 0);
function busyWork(ms) {
const end = performance.now() + ms;
while (performance.now() < end) { /* spin: this loop is the work */ }
}
const server = http.createServer(async (req, res) => {
if (random() < SLOW_CHANCE) await sleep(SLOW_MS);
busyWork(...);
res.end("ok\n");
});
One worker doing 10 ms of work has a rough capacity of 1000 / 10 = 100 requests per second. Call that an estimate. Scheduling, random service times, and bursts change the measured result, and that uncertainty is the whole point of the first experiment.
Big word: utilisation. The fraction of a worker's capacity that is currently demanded, usually written as a percentage. At 70 requests per second, this server is about 70% busy.
Experiment 1: the knee, or where a queue stops being polite
The obvious way to load-test a server is to send more traffic and watch the average. The problem is that the average is the last number to admit something is wrong.
As incoming work approaches the server's maximum rate, a queue starts forming before every request becomes slow. The high percentiles move first, because the most recent arrivals wait the longest. The experiment ramps the request rate and looks for the point where tail latency bends sharply upward. The knee.
| request/s | p99 |
|---|---|
| 60 | 69.4 ms |
| 70 | 157.6 ms |
At 70 requests per second the server is about 70% busy. Not full. Yet the slowest 1% of requests have already more than doubled to 157.6 ms. The M/M/1 estimate W = 1 / (mu - lambda) gives 33.3 ms average at that rate, and the measured mean tracked it early on, then drifted as the run got noisier near the knee.
Where the easy answer is not the full truth: people say "a server is fine until it's 100% busy, then you scale it." Not really. A queue forms well before that, and the tail feels it first. The knee is usually a fair bit below full utilisation. Your headroom is yours to choose; the knee just tells you where the comfortable margin ends.
Experiment 2: percentiles that lie, or how fan-out magnifies a small problem
A mean is one number: add every latency, divide by the count. It is easy to compute and easy to quote. It is also very good at hiding the rare, slow call that decides what a real user experiences. Percentiles show the boundary the slower traffic actually crosses: p95 is the latency that 95% of requests meet or beat.
Fan-out makes the problem worse, because a page is only as fast as its slowest parallel dependency. Every extra call is another chance to pick up a slow one. This experiment gave each dependency a 2% chance of taking 200 ms, then drove pages at 40 requests per second with increasing fan-out.
| calls per page | mean | p95 | slow pages observed |
|---|---|---|---|
| 1 | 4.0 ms | 1.5 ms | ~2% |
| 20 | 63.1 ms | 202.1 ms | 31% |
Read the mean column and the load test "sounds manageable": 4 ms to 63 ms. Read the p95 and the story inverts: 1.5 ms to 202.1 ms. And observe that about 31% of 20-call pages hit the 200 ms wall. The prediction 1 - (1 - 0.02)^20 ≈ 33% matches what the run observed. A 2% dependency, fanned out 20 ways, becomes a roughly one-in-three page failure.
Pros and cons of averaging. A mean gives you one stable number you can graph and compare. Its cost is that it averages away the rare slow calls that shape actual experience. Useful for spotting drift over time; dangerous as your only performance number.
Experiment 3: Little's law as a reality check on your own measurements
Little's law says the average number of requests in the system is L = lambda times W: the arrival rate multiplied by the average time each request spends in the system. The interesting bit is that it holds for almost any stable queue, so it works as a consistency check on your measurements.
| rate/s | L measured | lambda x W | M/M/1 L |
|---|---|---|---|
| 50 | 0.95 | 0.95 | 0.89 |
| 70 | 2.06 | 2.06 | 2.00 |
| 80 | 3.50 | 3.50 | 3.41 |
| 90 | 9.11 | 9.11 | 6.94 |
Measured L matched lambda x W across all four rows. The M/M/1 prediction stayed close at low load and drifted farthest at 90 requests per second, which is exactly where you would expect the classroom model to wobble.
Big word: Little's law. The relationship L = lambda x W between requests in flight, arrival rate, and time in the system. Because it holds for almost any stable system, when your measured L and your lambda x W stop agreeing, something is off with your measurement or your model. Investigate.
What I take away
- Watch percentiles, not just the average. The mean hid the real damage in both the knee and the fan-out experiments.
- Account for fan-out in your tail budget. A 2% dependency turns into a 31% page failure when it is 20 calls wide.
- Queues grow before the server looks bad. Latency is the earliest signal of saturation; the knee marks the end of comfortable headroom.
-
Use Little's law as a sanity check. When
Landlambda x Wstop agreeing, trust the mismatch and dig in.
Things that may matter, with no over-claiming on my part: these are finite runs on one machine. Event-loop scheduling, a seeded random source, and the load generator all add noise, and the M/M/1 formulas are a teaching model, not a promise. Treat the charts as tendencies and the relationships as intuition, not as a spec you can copy into a capacity plan.
Try it yourself
Clone system-design-challenges and run node challenges/challenge-1-measure-a-server/part-1-the-knee/knee.js, part-2-percentiles-lie/tail.js, and part-3-littles-law/little.js from the repo root. npm test from the root runs the checks. The repo grows module by module, and the numbers on your machine will be a little different, which is part of the lesson.

