Skip to content

Commit 6f85ea4

Browse files
committed
fix(benchmarks): quote the median run, not the best, and use the CI numbers
The first CI run showed why best-of-N is the wrong statistic here: node-tls-client ranged from 900 to 3095 req/s across its eleven runs, so its best would have put it above impit while its median sits a third below. That spread is intrinsic to the client rather than machine noise, which is exactly the case best-of-N flatters. The table now quotes the median, the result files keep best and worst, and a client whose runs swing by more than 1.5x gets a footnote saying so. The committed numbers are now the ones the workflow measured on a runner instead of the ones from a loaded laptop.
1 parent c5cd219 commit 6f85ea4

4 files changed

Lines changed: 39 additions & 26 deletions

File tree

README.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,35 +34,37 @@ async fn main() {
3434
<!-- comparison:start -->
3535
### Comparison
3636

37-
Sequential requests from a single client against the local HTTP/2 origin in [`benchmarks/`](benchmarks), 1 KiB JSON response, best of 11 runs of 2000 requests. Every client negotiated h2. Each one keeps a single connection warm for the whole run unless a footnote says otherwise. `Profiles` counts the distinct impersonation targets each public API accepts, ignoring aliases that resolve to another target. Python sizes are the platform wheel; Node.js sizes are what `npm install <package>` leaves on disk, transitive dependencies included.
37+
Sequential requests from a single client against the local HTTP/2 origin in [`benchmarks/`](benchmarks), 1 KiB JSON response, median of 11 runs of 2000 requests. Every client negotiated h2. Each one keeps a single connection warm for the whole run unless a footnote says otherwise. `Profiles` counts the distinct impersonation targets each public API accepts, ignoring aliases that resolve to another target. Python sizes are the platform wheel; Node.js sizes are what `npm install <package>` leaves on disk, transitive dependencies included.
3838

3939
**Python**
4040

4141
| Package | req/s | Wheel | Profiles | Backend |
4242
| --- | --- | --- | --- | --- |
43-
| [`primp`](https://github.com/deedy5/primp) | 2750 | 5.9 MB |[^1] | Rust |
44-
| [`rnet`](https://github.com/0x676e67/rnet) | 2735 | 3.7 MB | 75 | Rust |
45-
| **`impit`** | 2000 | 4.2 MB | 20 | Rust |
46-
| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 1338 | 41.3 MB | 51 | Go |
47-
| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 1116 | 13.5 MB | 38 | C (libcurl) |
48-
| `httpx` (no impersonation) | 797 | 0.1 MB || Python |
43+
| [`primp`](https://github.com/deedy5/primp) | 6214 | 5.9 MB |[^1] | Rust |
44+
| [`rnet`](https://github.com/0x676e67/rnet) | 5104 | 3.7 MB | 75 | Rust |
45+
| **`impit`** | 3780 | 4.2 MB | 20 | Rust |
46+
| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 3420 | 13.5 MB | 38 | C (libcurl) |
47+
| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 3211 | 41.3 MB | 51 | Go |
48+
| `httpx` (no impersonation) | 2323 | 0.1 MB || Python |
4949

5050
**Node.js**
5151

5252
| Package | req/s | Install | Profiles | Backend |
5353
| --- | --- | --- | --- | --- |
54-
| [`node-tls-client`](https://github.com/Sahil1337/node-tls-client) | 1566 | 30.7 MB | 63 | Go |
55-
| **`impit`** | 997 | 8.7 MB | 20 | Rust |
56-
| [`got-scraping`](https://github.com/apify/got-scraping) | 896 | 4.7 MB | 3[^2] | Node.js TLS |
57-
| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 201[^3] | 133.0 MB |[^4] | Go subprocess |
58-
| `undici` (no impersonation) | 2010 | 1.9 MB || Node.js |
54+
| **`impit`** | 2289 | 8.7 MB | 20 | Rust |
55+
| [`got-scraping`](https://github.com/apify/got-scraping) | 2234 | 4.7 MB | 3[^2] | Node.js TLS |
56+
| [`node-tls-client`](https://github.com/Sahil1337/node-tls-client) | 1659[^3] | 30.7 MB | 63 | Go |
57+
| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 626[^4][^5] | 133.0 MB |[^6] | Go subprocess |
58+
| `undici` (no impersonation) | 5064 | 1.9 MB || Node.js |
5959

60-
Measured on linux-x64 with CPython 3.12.13 and Node.js v24.16.0 on 2026-08-31. Hardware moves these numbers around, so rerun `benchmarks/` yourself before drawing conclusions.
60+
Measured on linux-x64 with CPython 3.12.3 and Node.js v24.19.0 on 2026-08-31. Hardware moves these numbers around, so rerun `benchmarks/` yourself before drawing conclusions.
6161

6262
[^1]: `primp` does not expose its profile list, and an unknown name silently falls back to a random profile rather than erroring, so the set cannot be counted.
6363
[^2]: `got-scraping` matches cipher suite and signature algorithm order only; it has no control over extension order, GREASE, or HTTP/2 `SETTINGS`. Its three profiles are not enumerable through the public API, so this count is hard-coded from its bundled cipher table.
64-
[^3]: `cycletls` opens a new connection for every request, so its figure includes a TLS handshake each time instead of reusing a warm one.
65-
[^4]: `cycletls` is configured with a raw JA3 string instead of named profiles, so it has no fixed set to count.
64+
[^3]: `node-tls-client` was erratic across runs — 900 to 3095 req/s — so its median says less than the others'.
65+
[^4]: `cycletls` was erratic across runs — 409 to 640 req/s — so its median says less than the others'.
66+
[^5]: `cycletls` opens a new connection for every request, so its figure includes a TLS handshake each time instead of reusing a warm one.
67+
[^6]: `cycletls` is configured with a raw JA3 string instead of named profiles, so it has no fixed set to count.
6668
<!-- comparison:end -->
6769

6870
### Other projects

benchmarks/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ download` to size each wheel.
2525

2626
[`server.mjs`](server.mjs) is the origin: a Node.js `http2` server on a self-signed certificate,
2727
serving a fixed 1 KiB JSON body over `h2` or `http/1.1`, whichever the client negotiates. Each
28-
client then issues sequential requests over one connection; the best of N runs is reported, which
29-
absorbs scheduler noise without flattering a client that is genuinely slow. Sequential single-client
30-
traffic is deliberate — it isolates per-request client overhead, which is what differs between these
31-
libraries, rather than measuring how well each one saturates a socket.
28+
client then issues sequential requests over one connection, N runs of it, and the **median** run is
29+
what the table quotes. Best and worst go into the result file too: some clients swing by 3x between
30+
runs on the same machine, and quoting the best would reward them for one lucky pass — where that
31+
spread is wide, `update-readme.mjs` footnotes it instead of pretending one number describes them.
32+
Sequential single-client traffic is deliberate: it isolates per-request client overhead, which is
33+
what differs between these libraries, rather than measuring how well each one saturates a socket.
3234

3335
The server also reports its connection count at `/__stats`, and both scripts record how many
3436
connections a client opened while being measured. That is what surfaces `cycletls` handshaking on

benchmarks/harness.mjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ export function parseArgs(argv, defaults) {
1919
}
2020

2121
/**
22-
* Runs `requests` sequential requests `runs` times and keeps the best run.
23-
* Sequential traffic over one warm connection isolates per-request client
24-
* overhead, which is what the comparison is about; best-of-N absorbs scheduler
25-
* noise without hiding a client that is genuinely slow.
22+
* Runs `requests` sequential requests `runs` times over one warm connection,
23+
* which isolates per-request client overhead — the thing that differs between
24+
* these libraries. The median is what the table quotes; the best and worst runs
25+
* come along because some clients swing by 3x between runs, and a best-of-N
26+
* figure would quietly reward them for one lucky pass.
2627
*/
2728
export async function measure(request, { requests, runs, warmup }) {
2829
for (let i = 0; i < warmup; i += 1) await request();

benchmarks/update-readme.mjs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,19 @@ const dominantAlpn = [...python.results, ...node.results]
4040
.map((result) => result.alpn)
4141
.reduce((agreed, alpn) => (agreed === alpn ? agreed : null));
4242

43+
/** Above this best-to-worst ratio a client's throughput is too unsteady to quote as one number. */
44+
const UNSTABLE_RATIO = 1.5;
45+
4346
/** Notes about how the throughput figure was reached, rendered next to it. */
4447
function throughputNotes(result, report) {
4548
const notes = [];
4649
if (result.alpn !== dominantAlpn) {
4750
notes.push(footnote(`\`${result.label}\` negotiated ${result.alpn} rather than ${dominantAlpn}.`));
4851
}
52+
if (result.rps > result.rpsWorst * UNSTABLE_RATIO) {
53+
notes.push(footnote(`\`${result.label}\` was erratic across runs — ${result.rpsWorst.toFixed(0)} to `
54+
+ `${result.rps.toFixed(0)} req/s — so its median says less than the others'.`));
55+
}
4956
const total = report.options.runs * report.options.requests;
5057
if (result.connections >= total / 2) {
5158
notes.push(footnote(`\`${result.label}\` opens a new connection for every request, so its figure `
@@ -57,12 +64,13 @@ function throughputNotes(result, report) {
5764
}
5865

5966
function table(report, sizeHeading) {
60-
const ordered = [...report.results].sort((a, b) => (a.baseline - b.baseline) || (b.rps - a.rps));
67+
const ordered = [...report.results]
68+
.sort((a, b) => (a.baseline - b.baseline) || (b.rpsMedian - a.rpsMedian));
6169
const rows = ordered.map((result) => {
6270
const name = result.repo ? `[\`${result.label}\`](${result.repo})` : `\`${result.label}\``;
6371
return [
6472
result.baseline ? `${name} (no impersonation)` : (result.repo ? name : `**${name}**`),
65-
`${result.rps.toFixed(0)}${throughputNotes(result, report)}`,
73+
`${result.rpsMedian.toFixed(0)}${throughputNotes(result, report)}`,
6674
formatMB(result.sizeBytes),
6775
`${result.profiles ?? '—'}${result.note ? footnote(result.note) : ''}`,
6876
result.backend,
@@ -78,7 +86,7 @@ function table(report, sizeHeading) {
7886
const { requests, runs, bodyBytes } = python.options;
7987
const caption = [
8088
`Sequential requests from a single client against the local HTTP/2 origin in [\`benchmarks/\`](benchmarks),`,
81-
`${bodyBytes / 1024} KiB JSON response, best of ${runs} runs of ${requests} requests.`,
89+
`${bodyBytes / 1024} KiB JSON response, median of ${runs} runs of ${requests} requests.`,
8290
dominantAlpn ? `Every client negotiated ${dominantAlpn}.` : '',
8391
'Each one keeps a single connection warm for the whole run unless a footnote says otherwise.',
8492
'`Profiles` counts the distinct impersonation targets each public API accepts, ignoring aliases that',

0 commit comments

Comments
 (0)