Before improving a distributed system, you need a measurable baseline. In this article, we'll load test an ASP.NET Core monolith with k6 and examine latency, throughput, checks, and failed requests as traffic increases.
Distributed Systems in .NET — Day 2
The goal is measurement, not blame. A load generator sits outside your process and only sees request duration and status codes. It can tell you that p95 latency moved; it cannot, on its own, tell you why. Keeping that distinction straight is most of what separates a useful load test from a misleading one.
🙏 Credit where it's due — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, Fundamentals of Distributed Systems on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.
What we'll build
A repeatable k6 load test for an ASP.NET Core monolith, with measurable thresholds and a saved performance baseline you can compare every later change against.
By the end you'll be able to:
- Run a smoke test to prove the test itself is wired up correctly
- Increase concurrency progressively through staged ramps instead of one blunt spike
- Read average vs. p95 latency and know why the average lies
- Distinguish HTTP failures (
http_req_failed) from failed checks (assertions in your script) - Save a reusable baseline so "is this faster?" becomes a question with an answer
The sample application here is Wassal, a deliberately naive food-delivery monolith: ASP.NET Core MVC, EF Core, SQL Server. Nothing in the method depends on it — point the same script at any HTTP endpoint you own.
Why a baseline comes first
Later in this series we add retries, timeouts, circuit breakers, bulkheads, and an outbox. Every one of those patterns trades something away — usually latency or throughput — to buy stability. Without a "before" measurement, you cannot tell whether a resilience pattern helped, hurt, or did nothing at all. You'd just be adding libraries and feeling safer.
So today produces one artifact: a saved, dated, reproducible set of numbers.
What k6 is
k6 is an open-source load-testing tool from Grafana Labs. Test scenarios are written in JavaScript, and its CLI reports latency, throughput, checks, and request failure metrics.
Two properties matter for us: the test is a file you commit (so a run is reproducible six months later), and thresholds are declared in the script (so a run has a pass/fail verdict instead of a wall of numbers you eyeball).
Install
On Windows, via winget:
winget install GrafanaLabs.k6 --accept-source-agreements --accept-package-agreements
Mind the package id. It's GrafanaLabs.k6, not k6.k6 — the latter is a plausible-looking guess that fails with No package found matching input criteria. (I lost time to exactly that.)
winget edits PATH, so open a fresh terminal before verifying:
k6 version
You should see something like k6.exe v2.0.0 (commit/8c3be52cc1, go1.26.3, windows/amd64). Everything below was verified against k6 v2.
Portable install — no winget, no admin, and you choose the drive
winget's --location flag is quietly ignored by some packages, so if you need k6 on a specific drive, download the release directly. This installs to D:\tools\k6 and puts it on your user PATH — no UAC prompt, no registry entries:
$dest = "D:\tools\k6"
New-Item -ItemType Directory -Path $dest -Force | Out-Null
$latest = Invoke-RestMethod "https://api.github.com/repos/grafana/k6/releases/latest"
$url = ($latest.assets | Where-Object name -like "*windows-amd64.zip").browser_download_url
Invoke-WebRequest $url -OutFile "$dest\k6.zip"
Expand-Archive "$dest\k6.zip" $dest -Force
Remove-Item "$dest\k6.zip"
# The zip expands into a version-named subfolder, so find the exe
$k6Dir = (Get-ChildItem -Path $dest -Filter k6.exe -Recurse | Select-Object -First 1).DirectoryName
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notlike "*$k6Dir*") {
[Environment]::SetEnvironmentVariable("Path", "$userPath;$k6Dir", "User")
}
$env:Path += ";$k6Dir" # current session too, so no restart needed
k6 version
Uninstalling is Remove-Item D:\tools\k6 -Recurse, and re-running the script upgrades in place.
Docker image
docker run --rm -i -v ${PWD}:/app -w /app grafana/k6 version
Two caveats on Windows, both about networking:
-
--network=hostbehaves differently on Docker Desktop than on Linux. Don't assume a container can reachlocalhost:5011on your machine. - From inside the container, your host application is usually reachable as
host.docker.internal, so you'd pass-e BASE_URL=http://host.docker.internal:5011.
On Windows either native install above is the simpler path. Use Docker only if neither is an option.
The load test script
The whole test is one file: scripts/load-tests/wassal-v0-stress.js. Here is the test logic verbatim — the summary-printing function at the end of the file is covered in the next section:
import http from 'k6/http';
import { check, sleep } from 'k6';
// Two modes, one script:
// smoke -> k6 run -e SMOKE=true scripts/load-tests/wassal-v0-stress.js (5 VUs, 10s, wiring check)
// full -> k6 run scripts/load-tests/wassal-v0-stress.js (staged ramp, ~2.5 min)
//
// The smoke mode is a separate options object rather than `--vus/--duration` flags so the
// two runs can differ in more than VU count (no thresholds, separate summary file).
const isSmokeTest = __ENV.SMOKE === 'true';
// p(99) is NOT in k6's default summaryTrendStats — ask for it explicitly or it reads as 0.
const TREND_STATS = ['avg', 'min', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'];
export const options = isSmokeTest
? {
vus: 5,
duration: '10s',
summaryTrendStats: TREND_STATS,
// No thresholds: a smoke run answers "is this wired up?", not "is this fast enough?".
}
: {
// Progressive load: warm up, then climb in 3 plateaus
// Total runtime: ~2.5 minutes (shortened for faster iteration)
stages: [
{ duration: '15s', target: 10 }, // ramp up to 10 vusers
{ duration: '30s', target: 10 }, // hold for 30s (baseline)
{ duration: '15s', target: 50 }, // ramp to 50
{ duration: '30s', target: 50 }, // hold for 30s (medium)
{ duration: '15s', target: 200 }, // ramp to 200
{ duration: '30s', target: 200 }, // hold for 30s (stress)
{ duration: '15s', target: 0 }, // ramp down
],
summaryTrendStats: TREND_STATS,
// These are thresholds, NOT guarantees. They may well fail — that is the useful part.
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
http_req_failed: ['rate<0.01'], // less than 1% errors
},
};
export default function () {
const baseUrl = __ENV.BASE_URL || 'http://localhost:5011';
const res = http.get(`${baseUrl}/`);
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1); // simulate user think time
}
Four decisions worth explaining:
Staged ramps, not a single spike. Three plateaus (10 → 50 → 200 VUs) with ramps between them let you see where behaviour changes. A single jump to 200 tells you only the end state.
Thresholds are declared, not hoped for. p(95)<500 and rate<0.01 are example targets for this lab, not universal web-app SLOs. Production thresholds should be derived from user expectations, business requirements, and agreed service objectives. k6 exits non-zero if they fail, which makes the script usable in CI later.
sleep(1) is think time — and a throughput ceiling. Each virtual user does one request, waits a second, repeats. That models a human clicking around rather than a hot loop. It also means N virtual users generate roughly N requests/second, no matter how fast your server is. Remember this; it explains the results below.
Smoke mode is an environment variable, not a CLI flag. You'll often see k6 run --vus 5 --duration 10s script.js recommended as a smoke test. On k6 v2 that does correctly override options.stages — I verified it, and the run reports 5 looping VUs for 10s. But the env-var switch is still better here, because a smoke run and a full run should differ in more than VU count: the smoke run skips thresholds and writes to a different summary file (more on that next).
One trap: don't let a smoke run overwrite your baseline
The script also exports handleSummary to print a readable table and save the raw metrics as JSON. That's where the trap is — a naive version saves to the same path on every run:
return {
'stdout': out,
'scripts/load-tests/summary.json': JSON.stringify(data, null, 2), // clobbers the baseline!
};
I hit this while writing this article: a 10-second smoke run silently replaced a 150-second baseline (11,760 iterations became 50). The saved file is version-controlled, so it was recoverable — but only because it was committed. The fix is one line:
// A smoke run must never overwrite the saved baseline.
const jsonPath = isSmokeTest
? 'scripts/load-tests/summary.smoke.json'
: 'scripts/load-tests/summary.json';
Commit your baseline files. Load-test results are data you'll want to diff, and they're trivially easy to destroy by accident.
Step 1 — Start the app on plain HTTP
Run on the HTTP profile so k6 doesn't have to deal with the ASP.NET Core dev HTTPS certificate:
dotnet run --project src/Wassal.Monolith --launch-profile http
Watch for Now listening on: http://localhost:5011 and leave it running. If your port differs, override the base URL rather than editing the script:
k6 run -e BASE_URL=http://localhost:5000 scripts/load-tests/wassal-v0-stress.js
Step 2 — Smoke test
In a second terminal, prove the plumbing works before spending 2.5 minutes on a real run:
k6 run -e SMOKE=true scripts/load-tests/wassal-v0-stress.js
Here's an actual smoke run against the app:
========================================================
WASSAL v0 — k6 SUMMARY (SMOKE)
========================================================
Test duration: 10.3s
Max vusers: 5
Total iterations: 40
LATENCY (http_req_duration)
avg: 282.06 ms
med: 9.37 ms
p(90): 2141.85 ms
max: 2141.85 ms
HTTP FAILURES (http_req_failed)
rate: 0.00 %
failed requests: 0
ok requests: 40
CHECKS
passed: 40
failed: 0
========================================================
Zero failures, 40/40 checks passed — the wiring is good. But look at that spread: a median of 9 ms and a max of 2,142 ms in the same run. That's cold start — JIT compilation, EF Core building its model, the first SQL connection opening. It's also a preview of the main lesson: with only 40 samples, one slow request dominates every percentile above p(50).
Never treat a smoke run as a baseline. Its job is to answer "does this test work?", nothing more.
Step 3 — Reading a k6 summary
Before the real run, three things people routinely misread.
Average vs. p95
The average is a poor summary of latency because it hides the tail. In the smoke run above, the average (282 ms) describes no actual request — it's one 2.1-second outlier smeared across 40 fast ones. The median was 9 ms.
p95 means that 95% of measured requests completed in this time or less, while the slowest 5% took longer. It's the number to hold yourself to, because those stragglers are real users having a bad time. Watch p95 and p99 move as load increases; that's the signal.
http_req_failed vs. failed checks
These count different things, and conflating them is the classic load-test reporting error:
| Metric | Counts | Example |
|---|---|---|
http_req_failed |
Transport errors, 4xx, 5xx | Connection refused, 500, 429
|
checks |
Assertions you wrote in the script | A 200 whose body is an error page |
A server can return a perfectly healthy 200 OK containing "Sorry, something went wrong" — http_req_failed stays at 0% while your check fails. Report both, always.
One naming trap if you parse the JSON yourself: http_req_failed is a k6 Rate metric where true means the request failed. So the failed count lives in passes, and the successful count lives in fails. Reading them the intuitive way inverts your report entirely.
Throughput
iterations/s is your effective request rate. With sleep(1), it's capped near the VU count — so if throughput plateaus at roughly your VU number, you're measuring your script's ceiling, not the server's.
Step 4 — The full run
k6 run scripts/load-tests/wassal-v0-stress.js |
Tee-Object -FilePath scripts/load-tests/run-output.txt
(Tee-Object is PowerShell; on bash use | tee scripts/load-tests/run-output.txt.) Forward slashes work fine in PowerShell paths — and they avoid the escape-sequence mangling that backslashes cause in many markdown renderers.
It takes about 2.5 minutes. While it runs, k6 prints live progress, and it's worth watching the application's console alongside it — request bursts, warnings, and connection errors show up there first.
Step 5 — The baseline
These are real measured results from my machine, not illustrative numbers — Windows 11, SQL Server (local), Kestrel, k6 v2.0.0, all on one laptop. Your absolute values will differ; the shape of the reasoning is the transferable part.
Endpoint: GET / — an EF Core query over a Restaurants table seeded with 5 rows, rendered through a Razor view.
| Metric | Value |
|---|---|
| Run duration | 150.79 s |
| Max VUs | 200 |
| Total requests | 11,760 |
| Throughput (avg) | 77.99 req/s |
| Latency — avg | 2.31 ms |
| Latency — median | 2.09 ms |
| Latency — p(90) | 3.67 ms |
| Latency — p(95) | 4.16 ms |
| Latency — max | 40.03 ms |
| HTTP failure rate | 0.00 % (0 of 11,760) |
| Checks passed | 11,760 / 11,760 |
Thresholds: p(95) < 500ms → PASS (4.16 ms). error rate < 1% → PASS (0.00 %).
A note on p(99): this run predates the
summaryTrendStatsline in the script above, so p(99) was never computed — k6 doesn't calculate it by default, and the old summary printed a misleading0 ms. That's exactly why the script now requests it explicitly. I'd rather flag the gap than quote a number I don't have.
The monolith didn't bend — and that's a result
The honest headline: at 200 virtual users, this monolith showed no distress whatsoever. p95 came in 120× under the threshold, with zero failed requests.
If you were expecting a dramatic collapse, that's the most useful thing in this article. A load test that comes back green means one of two things — the system is genuinely fine at this load, or you didn't apply enough load. Here it's clearly the second, and the numbers say so:
-
Think time capped the load.
iteration_durationaveraged 1,002.98 ms against a ~2 ms response. Over 99% of each virtual user's cycle wassleep(1). 200 VUs could therefore never generate much more than ~200 req/s regardless of server speed. The 77.99 req/s average is simply the mean across plateaus of 10, 50, and 200 VUs — a property of the script, not a limit of Kestrel. -
The work per request was trivial. A 5-row
SELECTagainst a warm local SQL Server, served from EF Core's connection pool. There's nothing here to exhaust. -
Server time was never the constraint.
http_req_waiting(time to first byte) averaged 1.99 ms with a p95 of 3.82 ms. The server answered essentially instantly, every time.
This is also where client-side metrics reach their limit. k6 can prove the system was fast; it cannot tell you which resource would have saturated first. That question needs server-side instrumentation — a topic for later in the series.
How to actually find the breaking point
To move from "it's fine" to "here's where it breaks", change the load, not the conclusion. In rough order of effect:
| Change | Why it moves the needle |
|---|---|
Remove or shrink think time (sleep(0.1), or drop sleep entirely) |
Directly lifts the per-VU request ceiling — the single biggest constraint here |
| Raise VU counts (1,000 → 5,000) | More concurrency against the same resources |
| Hit an endpoint that does real work — joins, writes, N+1 queries | Exercises the DB and connection pool instead of a 5-row read |
| Seed a realistic data volume | 5 rows exercise nothing; 500,000 exercise the query plan |
| Add a slow downstream dependency | The failure mode this series actually cares about |
| Run the load generator off-box | Removes competition for CPU between k6 and the app |
Swap sleep(1) for sleep(0.1) and the same 200 VUs will generate roughly ten times the request rate. That single change is usually enough to start seeing the curve bend.
One caution: k6 and the application were competing for the same laptop CPU. On a saturated box you can't cleanly separate "the server got slow" from "the load generator got slow". For a real capacity number, run them on separate machines.
Step 6 — Save the baseline
A baseline you can't find later isn't a baseline. Commit three things:
-
scripts/load-tests/wassal-v0-stress.js— the test itself -
scripts/load-tests/summary.json— raw k6 metrics, machine-readable for diffing -
scripts/load-tests/results-v0.md— the numbers plus your interpretation
That third file is the one people skip and later regret. Record the date, the hardware, the k6 version, the endpoint, the load profile — and what you concluded, including when the conclusion was "no pain found". Numbers without context are unfalsifiable six months on.
Tagging the commit makes the comparison point easy to retrieve:
git add scripts/load-tests/
git commit -m "test: add Day 2 k6 load-test baseline"
git tag day-02-load-test-baseline
Failure modes to watch for as load grows
None of these appeared in this baseline. They're the things to watch for as you turn up the pressure — and each maps to a pattern later in this series:
| Symptom | Likely cause | Pattern that addresses it |
|---|---|---|
| p95 climbs while the average stays flat | Queueing; a subset of requests waiting | Timeouts, load shedding |
| Latency rises but errors stay at zero | Kestrel request queue absorbing the backlog | Rate limiting, bulkheads |
| Timeout errors under concurrency | DB connection pool exhaustion | Pooling, async, bulkheads |
| One slow dependency degrades unrelated endpoints | Shared thread/connection resources | Bulkhead isolation |
| Retries make an outage worse | Retry storm against a downed dependency | Backoff + jitter, circuit breaker |
| You can't tell which layer got slow | No server-side instrumentation | Observability |
Notice the ordering: you can only recognize any of these against a known-good baseline. That's the whole reason today came first.
Takeaways
- A baseline is a prerequisite for every resilience change that follows, not a formality.
- Smoke test first; a 10-second run catches wiring mistakes before you spend minutes on a real one.
- Read p95, not the average — the average hides exactly the requests users complain about.
-
http_req_failedand failed checks measure different failures. Report both. - Think time silently caps throughput. If throughput ≈ VU count, you're measuring your script.
- A green load test is a real result. It means "fine at this load" — and it's your job to say what that load actually was.
Next: the monolith survives traffic, but not retries. Day 3 looks at what happens when a client retries a POST that already succeeded — the double-charge bug — and makes the endpoint idempotent so a retry is safe.
Part of the Distributed Systems in .NET series — building Wassal, a distributed food-delivery lab, one concept at a time.

Top comments (0)