lumo

Module 01 · Lesson 14 of 14Free

Lab: load testing a single server

Level
Intermediate
Topic
System design fundamentals
Reading time
20 min

When you finish this page you can

  1. Measure successful throughput, latency and errors under controlled load.
  2. Explain why adding clients can increase waiting without increasing throughput.
  3. Change one service-time assumption and predict the resulting capacity.
  4. Identify what a local load test cannot prove about production.

Builds on 3 lessons

Start with lesson 315 min
  1. Server concurrency: handling many requests at once15 min
  2. Latency and throughput: measuring speed and capacity14 min
  3. Design: a URL shortener on a single server30 min

Video lesson · coming soon

needs to handle 1,000 requests a second when its festival sale starts. One developer opens a product page, sees a response in about 95 ms, and declares the server ready.

But one fast request does not establish capacity. How can you measure when additional customers stop getting more completed work and start getting longer waits?

Isolate one limit before testing

You will build a local fixture: a small server with deliberately controlled behavior. It has one application slot. Every product request occupies that slot for a configured delay.

Kartly’s real product request includes CPU work, a database call and a pricing call. Here, a timer replaces the entire 95 ms request. There is no database or pricing service.

This is not a benchmark of Kartly’s eight-core server. It isolates one question from Server concurrency: what happens when requests share a limited number of occupied slots?

Check yourselfAnswer in your head first, then reveal
One request takes about 95 ms. If sixteen clients send requests continuously, should throughput become sixteen times higher?Show answerHide answer
Not with one application slot. Requests still finish one at a time; extra clients mostly wait for their turn.

Diagram not drawn yet: single-server-lab-one-slot.svg

A local load generator sends requests to one application slot through a waiting queue. Each request occupies the slot for 95 ms. Replies return to the generator, which records elapsed time and success.

More clients can fill the waiting line without creating another application slot.

The load generator will act as several clients. Each client sends another request only after its previous request finishes. This is a closed load model; slower responses also slow the generator’s next arrivals.3

That makes this experiment bounded and easy to interpret. It does not reproduce an independently arriving festival crowd.

Set up a bounded fixture

Use a desktop terminal with either Python 3.12.15 or .js 22.18.0 installed. These are pinned lab versions, not recommendations to deploy these versions.

Choose one language throughout. Run the server and generator in separate terminals. Both versions use only their runtime’s standard library.

bash
python3 --version
node --version
mkdir kartly-load-lab
cd kartly-load-lab

Only your chosen runtime must be available. On Windows, use py -3.12 wherever the commands show python3.

The TypeScript files use .mts, which Node treats as ES modules. Node 22.18.0 runs their erasable type annotations directly, without a compiler step. It does not type-check them.2

Save the matching server as server.py or server.mts.

python
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Semaphore

delay_ms: float = float(sys.argv[1]) if len(sys.argv) > 1 else 95.0
if not 0 <= delay_ms <= 1000:
    raise SystemExit("delay must be between 0 and 1000 ms")
slot = Semaphore(1)

class Handler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        if self.path != "/product":
            self.send_error(404)
            return
        with slot:
            time.sleep(delay_ms / 1000)
            body = b'{"product_id":"p_42"}'
            try:
                self.send_response(200)
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(body)))
                self.send_header("Connection", "close")
                self.end_headers()
                self.wfile.write(body)
            except OSError:
                pass  # A disconnected client must not stop the fixture.

    def log_message(self, format: str, *args: object) -> None:
        pass

class Server(ThreadingHTTPServer):
    request_queue_size = 128

print(f"127.0.0.1:8000; one slot; delay={delay_ms} ms", flush=True)
Server(("127.0.0.1", 8000), Handler).serve_forever()
ts
import { createServer } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";

const delayMs: number = Number(process.argv[2] ?? "95");
if (!Number.isFinite(delayMs) || delayMs < 0 || delayMs > 1000) {
  throw new Error("delay must be between 0 and 1000 ms");
}
let tail: Promise<void> = Promise.resolve();

const server = createServer((req, res) => {
  if (req.url !== "/product" || req.method !== "GET") {
    res.writeHead(404);
    res.end();
    return;
  }
  tail = tail.then(async (): Promise<void> => {
    await sleep(delayMs);
    if (res.destroyed) return;
    const body: string = '{"product_id":"p_42"}';
    res.writeHead(200, {
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(body),
      "Connection": "close",
    });
    res.end(body);
  });
  res.on("error", () => res.destroy());
});

server.listen(8000, "127.0.0.1", 128, () => {
  console.log(`127.0.0.1:8000; one slot; delay=${delayMs} ms`);
});

The Python server can receive requests on separate , but its semaphore permits only one handler into the delayed section. ThreadingHTTPServer supplies the threaded request handling. It is a teaching server, not a production server.1

The TypeScript server chains requests onto one promise. Each delayed section starts after the previous section ends. That chain is its application .

Both timers leave the CPU mostly idle. Low CPU use therefore does not imply spare application slots.

Try it yourself

Start one server:

bash
python3 server.py 95
# Or:
node server.mts 95

In the second terminal, make a single request:

bash
curl --noproxy "*" --max-time 5 -sS -o /dev/null -w "status=%{http_code} total=%{time_total}s\n" http://127.0.0.1:8000/product

On Windows, replace /dev/null with NUL; use curl.exe if your shell aliases curl.

Illustrative output:

text
status=200 total=0.097s

This measures the request from the client, including connection setup and reading the response. It is not an isolated timer for application work.

The request succeeds alone. Next, make requests overlap.

Count completed work and waiting

Save the matching generator as load.py or load.mts.

Each run attempts exactly 160 requests, without retries. The argument selects concurrent clients and must be between one and sixteen. The target is fixed to localhost.

python
import math
import sys
from concurrent.futures import ThreadPoolExecutor
from time import perf_counter
from urllib.request import ProxyHandler, Request, build_opener

clients: int = int(sys.argv[1]) if len(sys.argv) > 1 else 1
if not 1 <= clients <= 16:
    raise SystemExit("clients must be between 1 and 16")

def request(_: int) -> tuple[bool, float]:
    start = perf_counter()
    try:
        opener = build_opener(ProxyHandler({}))
        req = Request("http://127.0.0.1:8000/product",
                      headers={"Connection": "close"})
        with opener.open(req, timeout=5) as response:
            body = response.read()
            ok = response.status == 200 and body == b'{"product_id":"p_42"}'
    except Exception:
        ok = False
    return ok, (perf_counter() - start) * 1000

start = perf_counter()
with ThreadPoolExecutor(max_workers=clients) as pool:
    results = list(pool.map(request, range(160)))
elapsed = perf_counter() - start
latencies = sorted(ms for ok, ms in results if ok)
successes = len(latencies)
p95 = f"{latencies[math.ceil(0.95 * successes) - 1]:.0f}" if successes else "n/a"
print(f"success={successes} errors={160 - successes} "
      f"rps={successes / elapsed:.1f} p95_ms={p95}")
ts
import { performance } from "node:perf_hooks";

const clients: number = Number(process.argv[2] ?? "1");
if (!Number.isInteger(clients) || clients < 1 || clients > 16) {
  throw new Error("clients must be between 1 and 16");
}
let next: number = 0;
let errors: number = 0;
const latencies: number[] = [];

async function worker(): Promise<void> {
  while (next++ < 160) {
    const start: number = performance.now();
    try {
      const response = await fetch("http://127.0.0.1:8000/product", {
        headers: { Connection: "close" },
        signal: AbortSignal.timeout(5000),
      });
      const body: string = await response.text();
      if (response.status !== 200 || body !== '{"product_id":"p_42"}') {
        throw new Error("unexpected response");
      }
      latencies.push(performance.now() - start);
    } catch {
      errors++;
    }
  }
}

const start: number = performance.now();
await Promise.all(Array.from({ length: clients }, () => worker()));
const elapsed: number = (performance.now() - start) / 1000;
latencies.sort((a, b) => a - b);
const successes: number = latencies.length;
const p95: string = successes
  ? latencies[Math.ceil(0.95 * successes) - 1]!.toFixed(0) : "n/a";
console.log(`success=${successes} errors=${errors} ` +
  `rps=${(successes / elapsed).toFixed(1)} p95_ms=${p95}`);

The generator reports three different observations:

Field Meaning
success Responses with the expected status and body
errors Exceptions, , wrong status or wrong body
rps Successful requests divided by the full run’s elapsed seconds
p95_ms The nearest-rank 95th of successful request durations

starts when a client begins its request. It includes server-side waiting, but not time awaiting a free generator client.

Errors do not enter the . Always read the error count beside p95: fast successes can hide failed customers.

Use the definitions from Latency and throughput to predict the results before running.

Estimate the slot’s maximum rate

1 slot ÷ 0.095 seconds/request ≈ 10.5 requests/second

This is an ideal limit for the fixture. Connection and scheduling overhead can lower the measured rate.

Estimate four clients’ cycle time

4 clients ÷ 10.5 requests/second ≈ 0.38 seconds/request

Once the slot stays occupied, each client waits through roughly one turn of the line.

Estimate sixteen clients’ cycle time

16 clients ÷ 10.5 requests/second ≈ 1.52 seconds/request

More waiting is expected, not sixteen times more completed work.

These cycle-time estimates describe steady behavior, not exact percentile predictions. Starting and draining the run create shorter waits.

Add clients without adding capacity

Run the commands for your chosen language:

bash
python3 load.py 1
python3 load.py 4
python3 load.py 16
# Or:
node load.mts 1
node load.mts 4
node load.mts 16

Each baseline run should take roughly sixteen seconds. The following is illustrative output, not a recorded benchmark:

text
success=160 errors=0 rps=10.3 p95_ms=99
success=160 errors=0 rps=10.4 p95_ms=390
success=160 errors=0 rps=10.4 p95_ms=1540

Record your results in a small table. Repeat each run once. Do not compare tiny differences between repeats as evidence of a faster runtime.

The important pattern is stable with rising latency. Customers pay for the waiting even when every request eventually succeeds.

For this lab, assume Kartly wants p95 below 300 ms, with no errors. The four-client run can fail that target without failing a single request. Capacity must include an acceptable waiting time, not just eventual completion.

Should you keep increasing clients to find more capacity?

Increase client count beyond the lab’s bound.

The application slot stays occupied. More clients mostly increase request duration and can eventually produce timeouts.

Why this choice

More clients help only when the slot is idle because too little work arrives. Once it stays occupied, change its service time or add independent slots. We will change service time first because that isolates one variable.

A flat rate identifies a limit, but not automatically the production . Here, you know the limit because you built it.

Change one cause, then remeasure

Stop the server with Ctrl+C. Restart it with a 45 ms delay:

bash
python3 server.py 45
# Or:
node server.mts 45
Check yourselfAnswer in your head first, then reveal
With the same sixteen clients, what should happen after reducing the delay from 95 ms to 45 ms?Show answerHide answer
The ideal throughput rises from about 10.5 to about 22.2 requests a second. Steady client cycle time falls to about 720 ms, but that still exceeds the lab’s 300 ms target.

Repeat the same generator commands. The prediction follows directly from the fixture:

Recalculate the rate limit

1 slot ÷ 0.045 seconds/request ≈ 22.2 requests/second

The delay fell by a little more than half, so ideal throughput becomes a little more than twice as high.

Recalculate sixteen clients’ cycle time

16 clients ÷ 22.2 requests/second ≈ 0.72 seconds/request

Faster service helps every waiting client, but cannot make unlimited waiting acceptable.

This edit simulates shorter occupancy; it does not demonstrate a real database optimization. In a real application, first measure where the request spends time.

Adding independent slots is another option. It helps only if dependencies and resources can support the extra work. Vertical and horizontal scaling examines that decision.

Your experiment now supports a causal claim: changing occupied time changed throughput. It still does not establish production readiness.

Know what this run cannot prove

This generator waits before sending each client’s next request. At saturation, it cannot maintain an independent arrival rate. A festival crowd can keep arriving even when earlier customers are still waiting. An open load model separates arrival scheduling from completion.3

The fixture also omits , real product data, database contention and pricing failures. It is intentionally different from the preceding single-server URL shortener: this lab isolates queueing rather than benchmarking stored links.

Before testing a real service, write down the request mix and data size. Set a latency target and an error threshold. Arrange a stop condition before creating load.

If this lab reports errors, stop the generator first. Confirm the server is running, check its terminal, then retry with one client. Restarting clears the fixture’s pending work; there is no stored business data.

Never point this generator at production or a public website. For larger experiments, use a separate load-generating machine and measure the generator itself.

The next lesson follows a dependency this localhost experiment largely hides: TCP and UDP and how data travels between machines.

Common mistakes

  • Calling the largest rate “capacity”. Name the latency and error limits that made the rate acceptable.
  • Changing several variables together. Keep client count fixed when testing a service-time change.
  • Reporting p95 without errors. Failed requests are absent from this generator’s percentile.
  • Treating low CPU as proof of spare capacity. The fixture waits inside its only application slot.

Check your understanding

Check yourselfAnswer in your head first, then reveal
Throughput stays near ten requests a second, but p95 rises after adding clients. What changed?Show answerHide answer
More requests are waiting for the same occupied slot. Not service capacity: no extra slot or shorter service time was introduced.
Check yourselfAnswer in your head first, then reveal
A run reports fast p95 and eighty errors. Has it passed the lab’s target?Show answerHide answer
No. The target requires no errors, and p95 includes only successful requests. Failed customers cannot be ignored.
Check yourselfAnswer in your head first, then reveal
Can sixteen closed-loop clients prove that the server handles one thousand independent arrivals a second?Show answerHide answer
No. Each client waits for completion before sending again, so the test reduces its own arrival rate when responses slow.
Check yourselfAnswer in your head first, then reveal
You reduce the delay to zero, but throughput is not infinite. What should you investigate?Show answerHide answer
The remaining work includes connection setup, response handling and generator overhead. Removing the artificial delay exposes other limits.

Summary

  1. Measure completed requests, client latency and errors together to find when extra clients mostly add waiting.
  2. One occupied slot limits this fixture to roughly one completion per configured service interval.
  3. More clients can raise latency without increasing successful throughput.
  4. Change one cause, predict the result, then compare repeated measurements.
  5. A bounded localhost experiment teaches a mechanism; it does not prove production capacity.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.