Module 1: Why Load And Performance Testing

8. Mini-project: your first load measurement

Overview

This is the module's capstone, where you bring together everything learned and do it with your own hands, from start to finish. The mission is to make your first real load measurement against the Reservo API and, in parallel, leave the k6 equivalent written as content —to have the two sides of the coin you saw in lesson 7—. Concretely, you'll: (1) bring up the local Reservo API (the server from lesson 6) and confirm it responds; (2) write a mini load generator in Python that hits it with N concurrent requests to /quote and reports min/max/average and real p95 latency; (3) run it for real and read its numbers; and (4) write the equivalent k6 script as labeled content. By the end you'll have three artifacts —the server, the executed generator with its real output, and the k6 script— and, above all, you'll have answered with data a question a functional test can't: what p95 latency does my API have under N concurrent users?

Connection to the module: this project is the synthesis. It uses the target from lesson 6, the technique from lesson 7, the metrics from lesson 3, and the honesty of the environment rule (Python is run, k6 is content) from lesson 5. It's the rehearsal of what you'll do throughout the whole guide, in miniature. The guide's final project (module 8) is this same journey, but with complete load profiles, thresholds that gate, checks of a quote→book flow, and CI. Here you plant the seed.

What you'll deliver

A mini-project with four pieces:

  1. The Reservo API running. The reservo_server.py server from lesson 6, brought up on localhost (port 0), and a check that it responds the anchor number (/quote of Focus/basic/3h → 7500).
  2. The mini generator in Python. A script that launches N concurrent requests to /quote and reports min/max/average/p95 latency, plus the verification that all responses were correct.
  3. The generator's real output. The text your generator printed when you ran it —your measured numbers—, for at least two concurrency levels (to see the trend).
  4. The equivalent k6 script (content). The .js that would do the same test in k6, clearly labeled as content (not executed, unless you install k6).

The steps, one by one

Step 1 — Bring up the target

Where you are: you start from scratch; you need the API alive before measuring anything. Take the server from lesson 6 (reservo_server.py), save it, and start it. Have it write to port 0 to avoid colliding with other processes; the server leaves the chosen port in a PORT file.

What to expect — on startup, it prints the port; a test quote returns the anchor 7500. Real output (reference):

$ python3.14 reservo_server.py &
Reservo listening on http://127.0.0.1:51568

$ curl -s -X POST http://127.0.0.1:51568/quote \
    -H 'Content-Type: application/json' \
    -d '{"room":"Focus","tier":"basic","hours":3}'
{"price_cents": 7500}

If you see 7500, the target is ready. (This is also your smoke test from lesson 4: confirm the API is alive and responds correctly before launching load at it.)

Step 2 — Write the mini generator

Where you are: the target responds; now you build the measurement instrument. Write a script that: (a) receives the base URL, the total number of requests, and the concurrency; (b) uses a ThreadPoolExecutor to keep that concurrency of requests in flight; (c) measures each latency with time.perf_counter; and (d) reports min, average, max, and p95, plus whether all responses were 7500. You have the complete code in lesson 7; the challenge is understanding each part, not copying it blindly.

The three points that can't be missing:

  • The concurrency goes in max_workers. It's what creates the load (lesson 2). Without real concurrency, you measure isolated requests, not load.
  • The stopwatch surrounds only the HTTP request. perf_counter just before sending and just after receiving; nothing else inside.
  • The p95 is obtained by sorting and cutting. Sort the latencies and take the one at the 95% position.

Step 3 — Run it and read the numbers

Where you are: you have target and instrument; time to measure. Run the generator for at least two concurrency levels —for example 20 and 50— to see how the p95 responds. Run each one a couple of times: the numbers vary a bit between runs (that's normal; latency is a statistical measurement, not a fixed value).

What to expect — the higher the concurrency, the more contention, the higher the p95; all responses correct. Real output (reference, two levels):

$ python3.14 load_generator.py http://127.0.0.1:51568 200 20
requests ............ 200 (concurrency 20)
all returned ........ price_cents=7500 (correct: True)
total duration ...... 0.048 s
throughput .......... 4157.5 req/s
latency min ......... 2.67 ms
latency avg ......... 4.50 ms
latency max ......... 20.20 ms
latency p95 ......... 17.21 ms

$ python3.14 load_generator.py http://127.0.0.1:51568 1000 50
requests ............ 1000 (concurrency 50)
all returned ........ price_cents=7500 (correct: True)
total duration ...... 0.180 s
throughput .......... 5549.9 req/s
latency min ......... 4.84 ms
latency avg ......... 8.67 ms
latency max ......... 38.43 ms
latency p95 ......... 22.15 ms

Read your numbers with lesson 3's questions in hand: what's your p95 at each level? How much did it rise as concurrency increased? Does the average hide a tail (compare average with max)? With the reference 50-concurrent run, the average (8.67 ms) is less than half the p95 (22.15 ms) and less than a quarter of the max (38.43 ms): the tail is there, and only the p95 and the max show it. These are your measured data —what a functional test would never have given you—.

Step 4 — Write the k6 equivalent (content)

Where you are: you already measured for real with Python; now you leave written how it would be done with the industrial tool. Write the k6 script that does the same test —POST /quote with the same concurrency— and label it as content (you don't run it, unless you install k6). You have the model in lesson 7. What matters is that k6's vus conceptually matches your max_workers, and that k6's check verifies the same 7500 your generator checks.

// CONTENT (not run here, unless you install k6): k6 equivalent.
// It would run with: k6 run quote_test.js
import http from "k6/http";
import { check } from "k6";

export const options = {
  vus: 50,          // <-- matches the Python generator's max_workers=50
  duration: "10s",
};

export default function () {
  const url = "http://127.0.0.1:8000/quote";
  const payload = JSON.stringify({ room: "Focus", tier: "basic", hours: 3 });
  const params = { headers: { "Content-Type": "application/json" } };

  const res = http.post(url, payload, params);

  check(res, {
    "status is 200": (r) => r.status === 200,
    "price_cents is 7500": (r) => r.json("price_cents") === 7500,
  });
}

Save it next to the other artifacts. If someday you install k6, this file runs as-is against your Reservo API.

The reflection (part of the deliverable)

A load mini-project doesn't end in the numbers; it ends in what the numbers mean. Write a short paragraph answering:

  • What did your measurement answer that a functional test couldn't? (Hint: a functional test tells you /quote returns 7500; your generator tells you how long it takes under N concurrent users —a different property, this guide's—.)
  • How did your p95 change as concurrency rose, and what does that suggest?
  • Why would you report the p95 and not the average if you had to promise a latency to a client?

Self-assessment rubric

CriterionInsufficientGoodExcellent
API brought upDoesn't start or doesn't return 7500Starts and returns the anchorStarts on port 0 and you verify /rooms, /quote (basic and pro), and /book
Real concurrencyConcurrency 1 (isolated requests)Uses a pool with max_workers > 1You measure at two or more concurrency levels and compare
Latency measuredOnly average, or badly timedYou report min/average/max/p95You also compare p95 vs average and explain the tail
Correctness under loadYou don't verify the responsesYou confirm all give 7500You report it explicitly (correct: True)
k6 script (content)Absent or presented as executedPresent and labeled as contentLabeled, and the vus/check correspond to the generator
ReflectionAbsentYou explain what you measuredYou connect p95↔average↔user experience

Aim for "Excellent" in at least the concurrency and latency columns: they're the heart of what this guide teaches.

Common mistakes (in this project)

Delivering a concurrency-1 run and calling it a "load test." Without concurrency there's no contention, and the p95 comes out almost equal to the average (you saw it in lesson 2: 0.35 vs 0.33 ms). Raise it; load is born from simultaneity.

Presenting the k6 summary as if you had run it. If you didn't install k6, you didn't run it. Label the k6 script and summary as content; your measured numbers are the Python generator's. This honesty is part of the discipline of performance testing, not a technicality.

Trusting a single run. Latency varies between runs; a single snapshot can mislead. Run each level a couple of times and see if the numbers are stable. (In the guide we'll go deeper into how to make this rigorous.)

Exercises

Exercise 1 — Extend the report. Modify (mentally or in code) your generator to also report the p50 (median) and the p99. (a) How would you compute them from the sorted list of latencies? (b) In the reference 50-concurrent run (average 8.67, p95 22.15, max 38.43), would you expect the p99 to be closer to the p95 or the maximum, and why?

See solution
  • (a) With latencies sorted: p50 = latencies[int(len(latencies) * 0.50)] and p99 = latencies[int(len(latencies) * 0.99)] (with the same care about not going out of index that the p95 already has). Or with statistics.quantiles(latencies, n=100) to get all the percentiles at once (we'll see it in module 3).
  • (b) The p99 would be between the p95 (22.15) and the maximum (38.43), probably closer to the p95 than the maximum. The maximum is the single worst case —one unlucky request—, while the p99 is still a percentile (the worst 1%), which usually stays below the absolute extreme. The distance between p99 and max tells you how "spiky" the tail is: if the max shoots well above the p99, there were one or two isolated extreme cases.

Exercise 2 — Design a smoke before the load. Before your big run, what minimal smoke test would you run to avoid wasting a long test with a broken script? Describe the command and what you'd look at in its output.

See solution

A smoke would be running the generator with very little load —for example python3.14 load_generator.py http://127.0.0.1:PORT 5 1 (5 requests, concurrency 1)—. In its output I'd look at correct: True: that the few responses were the expected 7500, confirming the script points correctly, the JSON body is right, and the API responds. The latency numbers with 5 requests don't matter; the smoke only validates that the whole scaffolding works before launching 1000 concurrent. (Golden rule from lesson 4: always the smoke first.)

Exercise 3 — Interpret for a non-technical person. Your boss, who isn't technical, sees your 50-concurrent report (average 8.67 ms, p95 22.15 ms) and asks: "So the app responds in 8.67 milliseconds?". Answer them in two or three sentences, without jargon, correcting honestly and using the right figure.

See solution

An example of an honest, jargon-free answer: "8.67 ms is the average, but the average hides the users who had it worst. The more honest figure is that 95% of requests responded in 22 ms or less —that is, 1 in every 20 users waited a little more than that—. Promising 8.67 would be keeping the pretty part; 22 ms (the p95) is what we can really guarantee for almost everyone." What matters is not selling the average as if it were everyone's experience, and using the p95 as the sustainable promise.

Summary and next step

In this mini-project you made your first real load measurement, from start to finish and with your own hands: you brought up the Reservo API (confirming the 7500 anchor), wrote and ran a mini generator in Python that hits it with N concurrent requests and reports min/max/average/p95 latency, read your measured numbers at two concurrency levels (seeing the p95 rise with the load), and left the equivalent k6 script written as content. And —most important— you answered with data a question no functional test can: what p95 latency does my API have under load?

With this you close module 1. You now know why performance testing exists (it's a different question from correctness: does it hold up? versus does it work?), what questions it answers (capacity, p95 latency, breaking point), what types there are (smoke, load, stress, spike, soak), what k6 is and its own runtime, what the canonical Reservo API looks like, and what it feels like to launch load and measure —with real numbers—. You have the complete map and you already took the first executed step.

What comes next is opening the industrial tool for real. In module 2 we dissect the anatomy of a k6 script and the VU model: what exactly a virtual user is, how the default function is its loop, how http.get/post, check, and sleep compose an iteration, and how export const options shapes the load. Everything you saw here "from afar" in the k6 script, over there we open piece by piece —and the Python generator will stay your executable test bench for every concept—.

Resources