1.3M requests, and the most serious defect had no error code at all.

Basquin was pointed at real, unmodified JVM web apps — no test code, no instrumentation in the app, no config changes. It found errors, which any load tool would have found too. It also found 3,887 responses that were neither an error nor the thing that was asked for, and — on a target that was still answering 200 in 10 ms — two threads that had been spinning at 100% of a core for five hours.

Apache JSPWiki 2.12.4 MyBatis JPetStore-6 Apache Roller 6.1.5 Tomcat 9 · unmodified
1.3M
requests driven across three unmodified apps
49k
server errors — the part a crash oracle does report
3,887
3xx responses classified by destination instead of followed
2
threads spun at 100% of a core for 5 hours, while the app answered 200 in 10 ms

The gap this measures

A crash-only oracle — a plain fuzzer, a load test's error count — asks one question: did it return an error? That question is not useless: it catches the 49k server errors below, and those are real. But it is the only question those tools ask, and the two most serious things on this page do not answer it. A write the app silently threw away returns 302. A thread spinning forever at 100% of a core returns 200 in 10 ms.

Each row splices two separate campaigns: the coverage and breach columns come from that app's explore run, the traffic columns from a different, later load run at the highest concurrency tested. They are not two views of one run, and a number in one group says nothing about the other. The tiles above aggregate every collected load run (6 of them), so their totals exceed this table's.
from the explore campaign from the load campaign — no invariants evaluated
App Coverage Invariant breaches Failed requests Requests req/s p50 / p99 ms 5xx
Apache JSPWiki 2.12.4
wiki CMS · filesystem store
27.1% 2918 17 17k 55.2 2 / 6780 398
MyBatis JPetStore-6
e-commerce · MyBatis + HSQLDB
23.1% 421 470 1.1M 3511.8 1 / 9 39047
Apache Roller 6.1.5
blog CMS · JPA + Postgres
30.5% 1402 0 4,670 15.5 515 / 532 0
These counts are now complete — and it took a fix to make them so

The valve evaluates every invariant inside the app JVM and logs it, then attaches the result to the response — but only if the response has not already been committed, and on a real app most responses have. The driver learned about violations solely by reading that header, so a finding on a committed response was evaluated, logged, and thrown away. DD-040 added a side channel that recovers them and DD-039 carries it across redirects; the numbers on this page are from that fixed channel.

How much was being lost, measured before the fix: Roller reported 0 of the violations it evaluated (one explore window evaluated 1,906 and reported none). On the pre-fix run those two apps reported almost nothing — Roller 0, JSPWiki 1 (summaries preserved at git 5655c46, pruned from the tree by this run) — while JPetStore lost nothing because its small pages rarely commit early, which is why it looked productive and the other two looked clean. On the fixed channel the same explore runs report Roller 1,402, JSPWiki 2,918, and JPetStore 421: Roller, which "looked underwhelming", is the most productive target and has the highest coverage here.

The loss was biased the wrong way — the larger and slower the response, the more likely it committed early, so the most expensive requests were the most likely to be discarded. Evaluation was always intact; only the reporting was lost, and that is what the fix restored. The loss magnitudes are the ones DD-040 measured (see its decision record); the recovered violations are archived under bench-results/violation-logs-2026-07-23/.

Those two columns are split deliberately, because they are not the same kind of evidence and the campaign resource's single findings total blends them (along with inputs saved purely for reaching new code, which are not defects at all). Invariant breaches are the strong signal: the valve, running inside the app JVM, measured a request that exceeded its latency, heap, or thread budget. Failed requests are weaker: the driver's HTTP call threw — a connection reset, a read timeout, or a protocol error. That is an availability symptom, but this run does not attribute it between the app, the driver, and the single-node cluster, so it is reported as what was observed rather than as a defect count. See Methodology.

Silent rejection: the finding a load tool cannot have

JSPWiki's editor is guarded by two per-session tokens — an anti-CSRF field, and an anti-spam hash whose field name is six random letters that rotate. Basquin correlates both out of the edit form and replays the write, so the saves are well-formed: the run's captureMisses counter — reported in the figure below — says how often a required prior capture failed to bind, and the rest were submitted with both tokens.

What comes back is a 302 either way. A save that lands and a save the app throws away are the same status code, the same absence of any error, and the same entry in a load tool's success column. The only thing that separates them is the Location header — which is precisely what a client destroys when it follows the redirect for you. So Basquin stops following, and reads it.

Where 3,887 redirects went — JSPWiki under load, classified by Location
Wiki.jsp2,82073%· the save echo — a write that landed Login.jsp68017%· a view request with an EMPTY page name, which JSPWiki bounces to the login screen with or without a session Forbidden.html38610%· CSRF token missing or wrong PageModified.jsp10%· concurrent-edit conflict — the save was refused
Every one of these was a 3xx: not a 5xx, not a 4xx, and every CSRF and anti-spam token was submitted (captureMisses: 340 — steps skipped because a prior capture never bound, counted separately from the requests below), so a load tool counts all 3,887 as served. Split by destination they resolve into 387 refused writes and 2,820 accepted ones — a distinction carried entirely by a response header that a redirect-following client discards before anyone can read it.
The first version of this chart was wrong in both directions

Worth stating, because it is the reason to trust the second one. The first run of this classifier put Login.jsp at the top and it was read as rejected writes. Probing the running app showed they are not writes at all — they are view requests carrying an empty page name, which JSPWiki bounces to the login screen whether or not a valid session is presented. Meanwhile a bucket named for a page turned out to be successful saves that the fold had missed, because JSPWiki capitalizes the first letter of an unresolved page name (ndwsNdws) and the fold compared bytes.

The serious one was the opposite mistake. A genuine concurrent-edit conflict redirects to PageModified.jsp?page=<the page being saved>, and because that page name matches the request's own, the rule filed a real rejection into the success bucket — the exact invisibility the feature exists to eliminate, reintroduced by its own classifier. Same-page redirects now key by the destination's path, so every success shares one bounded key while a same-page rejection route keeps its own. Every label in the chart above was verified against the running app rather than inferred from the counts.

What the load actually cost

Latency percentiles from the driver, which measures wall-clock per request against the real app over the whole run — warm-up excluded, no sampling.

Apache JSPWiki 2.12.4 — 17k requests at 55.2 rps (concurrency 8)
1 10 100 1,000 10k p50 2ms p90 315ms p99 6,780ms max 9,568ms log scale · ms
Median 2 ms, but a worst single request of 9,568 ms — a 4784× spread that a median-only dashboard hides entirely.
MyBatis JPetStore-6 — 1.1M requests at 3511.8 rps (concurrency 8)
1 10 100 p50 1ms p90 5ms p99 9ms max 35ms log scale · ms
Median 1 ms, but a worst single request of 35 ms — a 35× spread that a median-only dashboard hides entirely.
Apache Roller 6.1.5 — 4,670 requests at 15.5 rps (concurrency 8)
1 10 100 1,000 p50 515ms p90 518ms p99 532ms max 558ms log scale · ms
Median 515 ms, but a worst single request of 558 ms — a 1× spread that a median-only dashboard hides entirely.

How much of the app gets reached

Findings only count if the explorer gets somewhere interesting. Coverage is measured by JaCoCo against the app's own classes, driven by a request grammar rather than recorded traffic — so it reaches authenticated write paths, not just the pages a crawler can see.

Bytecode coverage reached by grammar-guided exploration
27.1% JSPWiki 2.12.4 23.1% JPetStore-6 30.5% Roller 6.1.5
JaCoCo-measured coverage of the app's own classes, reached without a single line of test code — just a request grammar. Invariant breaches the driver actually RECEIVED in the same runs: JSPWiki reported 2918 · JPetStore-6 reported 421 · Roller reported 1402. These come from the fixed reporting channel, so they are the violations actually evaluated, not a lower bound.

Heap allocated per request

Every number above is visible from outside the app. This one is not, and it is the reason the valve runs in-process.

Heap allocated per request, measured inside the app JVM
945 KB 298 KB median 1,054 KB 392 KB p90 1,540 KB 513 KB p99
This is the measurement no external load tool can take: the valve reads the JVM's own allocation counter on either side of the request, so the number is the app's real cost for that one input — not a process-wide average.

The cold cliff

8408 ms & 10.7 MB on request #1 → ~33 ms steady

The very first request of the soak (JSPWiki front page) took 8408 ms and allocated 10.7 MB — then the app settled to ~33 ms. The first-request-after-deploy cliff (JSP compilation plus cold-cache markup rendering) is an availability pathology a warmed-up load test never sees and a crash test never flags — and it is exactly what a user hits after every deploy, restart, and scale-up event.

Source: bench-results/jspwiki/findings-summary.txt (iteration 1: 8408 ms + 10713 KB) and the k6 steady-state median (33 ms).

Turning findings into changes

A finding is only useful if it names something a team can go fix. Each class of finding maps to a specific kind of work:

Per-request heap over budget

Names the exact input that allocated. Usually a render path building the whole response in memory, or an unbounded result set. The fix is streaming or a bound — and the same run re-measures it.

signal: heapDelta > budget on a specific route

Latency spread, not latency

A p50 that looks fine next to a p99 orders of magnitude worse points at a cliff — a cold cache, a lock, an N+1 — not at general slowness. Capacity planning off the median under-provisions for exactly this.

signal: p99/p50 ratio, plus the worst single input

Silent rejection

Writes that return a success-shaped status and don't land. Points at session handling, CSRF/anti-automation defences, or concurrency conflicts — and invalidates any load number measured on that path, because the expensive half never ran.

signal: 3xx classified by Location, not followed

Retention across a run

Separating "this request is expensive" from "this run is leaking" is the question that matters at 3am. Measuring it needs GC-bracketed sampling, not a raw used-heap delta — see the methodology note below for why this page publishes no drift number.

signal: retained heap after a forced collection, sampled repeatedly

Methodology & honesty

next

Want to run this same battery against your own app? Read Benchmarking & target onboarding → — packaging an unmodified WAR, authoring a request grammar, and running the load comparison.