From c5cd219255960493423d06ebede76b8de2513010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Mon, 31 Aug 2026 15:20:51 +0200 Subject: [PATCH 1/9] feat(benchmarks): measure the client comparison instead of hand-writing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comparison table added in #523 was measured by hand, so it goes stale as soon as any of the compared projects ships a release and nobody can check it. This replaces the numbers with a harness that produces them: a local Node.js HTTP/2 origin, one benchmark script per ecosystem driving the latest published release of every client, and a script that rewrites the table between markers in the README. A monthly workflow runs the lot and opens a PR when the numbers move. Nothing is pinned and no lockfile is committed — the point is to compare what the ecosystems ship today, impit included. Two things the harness found that the hand-written table got wrong. got-scraping was reported as HTTP/1.1-only because HTTP/2 died with a GOAWAY after roughly a thousand requests; that was Node's Rapid-Reset mitigation on the test server counting got's per-response RST_STREAM, not a got-scraping defect, so it is switched off in the origin and got-scraping is now measured over h2 like everything else. And the origin reports its connection count, which shows cycletls opening a fresh TLS connection per request rather than reusing a warm one — previously invisible in its throughput number, now footnoted. --- .github/workflows/comparison-benchmark.yaml | 101 ++++++ .gitignore | 6 + README.md | 37 +- benchmarks/README.md | 60 ++++ benchmarks/harness.mjs | 85 +++++ benchmarks/node/bench.mjs | 270 +++++++++++++++ benchmarks/node/package.json | 16 + benchmarks/python/bench.py | 360 ++++++++++++++++++++ benchmarks/python/requirements.txt | 7 + benchmarks/server.mjs | 96 ++++++ benchmarks/update-readme.mjs | 126 +++++++ 11 files changed, 1148 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/comparison-benchmark.yaml create mode 100644 benchmarks/README.md create mode 100644 benchmarks/harness.mjs create mode 100644 benchmarks/node/bench.mjs create mode 100644 benchmarks/node/package.json create mode 100644 benchmarks/python/bench.py create mode 100644 benchmarks/python/requirements.txt create mode 100644 benchmarks/server.mjs create mode 100644 benchmarks/update-readme.mjs diff --git a/.github/workflows/comparison-benchmark.yaml b/.github/workflows/comparison-benchmark.yaml new file mode 100644 index 00000000..b866486d --- /dev/null +++ b/.github/workflows/comparison-benchmark.yaml @@ -0,0 +1,101 @@ +name: Refresh comparison benchmark + +on: + schedule: + # First of the month; the table tracks other projects' releases, not ours. + - cron: '0 4 1 * *' + workflow_dispatch: + inputs: + requests: + description: Requests per run + default: '2000' + runs: + description: Runs per client, best one wins + default: '11' + # Exercise the harness whenever it changes, without proposing anything. + pull_request: + paths: + - benchmarks/** + - .github/workflows/comparison-benchmark.yaml + +permissions: + contents: read + +concurrency: + group: comparison-benchmark + cancel-in-progress: false + +env: + BRANCH: benchmarks/comparison-refresh + REQUESTS: ${{ inputs.requests || '2000' }} + RUNS: ${{ inputs.runs || '11' }} + +jobs: + benchmark: + name: Measure and open a PR + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event_name == 'pull_request' && github.sha || 'master' }} + token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN || github.token }} + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + + - name: Setup uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + # No lockfiles anywhere here on purpose: the point is to compare whatever + # each project publishes today. + - name: Install the Node.js clients + run: npm install --prefix benchmarks/node --no-audit --no-fund --no-package-lock + + - name: Install the Python clients + run: | + uv venv --seed --python 3.12 benchmarks/python/.venv + uv pip install --python benchmarks/python/.venv/bin/python -r benchmarks/python/requirements.txt + + - name: Benchmark the Node.js clients + run: node benchmarks/node/bench.mjs --requests "$REQUESTS" --runs "$RUNS" + + - name: Benchmark the Python clients + run: benchmarks/python/.venv/bin/python benchmarks/python/bench.py --requests "$REQUESTS" --runs "$RUNS" + + - name: Rewrite the README table + run: node benchmarks/update-readme.mjs + + - name: Upload the raw measurements + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: always() + with: + name: comparison-results + path: benchmarks/results-*.json + + - name: Open a pull request + if: github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} + run: | + if git diff --quiet -- README.md; then + echo "The measurements did not move the table; nothing to propose." + exit 0 + fi + + git config user.name 'apify-service-account' + git config user.email 'apify-service-account@users.noreply.github.com' + git checkout -B "$BRANCH" + git commit -m 'docs: refresh the client comparison benchmark' -- README.md + git push --force origin "$BRANCH" + + if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then + gh pr create --base master --head "$BRANCH" \ + --title 'docs: refresh the client comparison benchmark' \ + --body "Measured by \`.github/workflows/comparison-benchmark.yaml\` against the local origin in \`benchmarks/\`, $RUNS runs of $REQUESTS requests per client. Raw numbers are attached to [the run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) as an artifact." + else + echo "The existing pull request now points at the new measurements." + fi diff --git a/.gitignore b/.gitignore index 43076265..003ba457 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,9 @@ venv *.so *.py[cod] _build/ + +# Benchmarks +/benchmarks/.cert +/benchmarks/results-*.json +/benchmarks/node/node_modules +/benchmarks/node/package-lock.json diff --git a/README.md b/README.md index c8e43ae2..5b7e472f 100644 --- a/README.md +++ b/README.md @@ -31,34 +31,39 @@ async fn main() { } ``` + ### Comparison -Sequential requests from a single client against a local Node.js HTTP/2 server (1 KiB JSON body, keep-alive), best of 11 runs of 2000 requests, pinned to one core. Profile counts are the distinct versioned impersonation targets exposed by the public API. Numbers are indicative — rerun them on your own hardware before drawing conclusions. +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 ` leaves on disk, transitive dependencies included. **Python** | Package | req/s | Wheel | Profiles | Backend | | --- | --- | --- | --- | --- | -| [`rnet`](https://github.com/0x676e67/rnet) | 3808 | 3.7 MB | 75 | Rust | -| [`primp`](https://github.com/deedy5/primp) | 3547 | 5.3 MB | n/a[^1] | Rust | -| **`impit`** | 2885 | 4.3 MB | 20 | Rust | -| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 1831 | 41.3 MB | 51 | Go | -| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 1548 | 13.5 MB | 38 | C (libcurl) | -| `httpx` (no impersonation) | 759 | 0.1 MB | — | Python | +| [`primp`](https://github.com/deedy5/primp) | 2750 | 5.9 MB | —[^1] | Rust | +| [`rnet`](https://github.com/0x676e67/rnet) | 2735 | 3.7 MB | 75 | Rust | +| **`impit`** | 2000 | 4.2 MB | 20 | Rust | +| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 1338 | 41.3 MB | 51 | Go | +| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 1116 | 13.5 MB | 38 | C (libcurl) | +| `httpx` (no impersonation) | 797 | 0.1 MB | — | Python | **Node.js** | Package | req/s | Install | Profiles | Backend | | --- | --- | --- | --- | --- | -| **`impit`** | 1353 | 8.7 MB | 20 | Rust | -| [`got-scraping`](https://github.com/apify/got-scraping) | 1149[^2] | 5.2 MB | 3[^3] | Node.js TLS | -| [`node-tls-client`](https://github.com/Sahil1337/node-tls-client) | 901 | 31.1 MB | 63 | Go | -| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 287 | 133.3 MB | raw JA3 | Go subprocess | -| `undici` (no impersonation) | 2030 | 2.0 MB | — | Node.js | - -[^1]: `primp` accepts arbitrary version strings and snaps to the nearest shipped profile, so the set is not enumerable through the public API. -[^2]: Over HTTP/1.1 — with HTTP/2 enabled the server closes the session with `GOAWAY` after roughly a thousand requests. -[^3]: Cipher suite and signature algorithm order only; no control over extension order, GREASE, or HTTP/2 `SETTINGS`. +| [`node-tls-client`](https://github.com/Sahil1337/node-tls-client) | 1566 | 30.7 MB | 63 | Go | +| **`impit`** | 997 | 8.7 MB | 20 | Rust | +| [`got-scraping`](https://github.com/apify/got-scraping) | 896 | 4.7 MB | 3[^2] | Node.js TLS | +| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 201[^3] | 133.0 MB | —[^4] | Go subprocess | +| `undici` (no impersonation) | 2010 | 1.9 MB | — | Node.js | + +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. + +[^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. +[^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. +[^3]: `cycletls` opens a new connection for every request, so its figure includes a TLS handshake each time instead of reusing a warm one. +[^4]: `cycletls` is configured with a raw JA3 string instead of named profiles, so it has no fixed set to count. + ### Other projects diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..8d166176 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,60 @@ +# Comparison benchmark + +Produces the comparison tables in the root [`README.md`](../README.md). Everything here measures the +**latest published** release of each client, `impit` included — nothing is pinned and no lockfile is +committed, so a run always reflects what the ecosystems ship today. + +## Running it + +```bash +npm install --prefix node --no-audit --no-fund +uv venv --seed --python 3.12 python/.venv +uv pip install --python python/.venv/bin/python -r python/requirements.txt + +node node/bench.mjs # writes results-node.json +python/.venv/bin/python python/bench.py # writes results-python.json +node update-readme.mjs # rewrites the table in ../README.md +``` + +`--requests`, `--runs` and `--warmup` shrink a run while iterating, and `--only impit,undici` limits +it to a few clients. `update-readme.mjs` refuses to run when the two reports disagree on the +parameters, so pass the same values to both. `uv venv --seed` matters: `bench.py` shells out to `pip +download` to size each wheel. + +## What is measured + +[`server.mjs`](server.mjs) is the origin: a Node.js `http2` server on a self-signed certificate, +serving a fixed 1 KiB JSON body over `h2` or `http/1.1`, whichever the client negotiates. Each +client then issues sequential requests over one connection; the best of N runs is reported, which +absorbs scheduler noise without flattering a client that is genuinely slow. Sequential single-client +traffic is deliberate — it isolates per-request client overhead, which is what differs between these +libraries, rather than measuring how well each one saturates a socket. + +The server also reports its connection count at `/__stats`, and both scripts record how many +connections a client opened while being measured. That is what surfaces `cycletls` handshaking on +every request rather than reusing a warm connection; without it the number would look like plain +per-request overhead. + +The `Profiles` column counts the distinct impersonation targets each public API accepts, minus +aliases that merely resolve to another target. There is no uniform way to ask for that, so every +client has its own small accessor in the two `bench.py`/`bench.mjs` client tables; where a library +does not expose the set at all, the count is dropped and a footnote says why. + +Sizes are the artifact each ecosystem actually distributes: for Python the platform wheel that `pip +download` picks, for Node.js what a fresh `npm install ` leaves on disk with its transitive +dependencies. + +## Notes on the origin + +Node's HTTP/2 Rapid-Reset mitigation is switched off in `server.mjs`. Clients that `RST_STREAM` each +response once they have read it — got's `http2-wrapper` does — otherwise exhaust the default budget +of 1000 resets and are hit with a `GOAWAY` a thousand requests into a run. The mitigation is correct +for a public origin and wrong here, where the server must never be the thing that rate-limits the +client. + +## Adding a client + +Add an entry to `CLIENTS` in the relevant script: how to build it, how to issue one request, how to +count its profiles, and a `note` if either the profile count or the throughput figure needs a caveat. +`update-readme.mjs` picks up the rest — ordering, footnote numbering and the caption — from the two +result files. diff --git a/benchmarks/harness.mjs b/benchmarks/harness.mjs new file mode 100644 index 00000000..dd43b7ba --- /dev/null +++ b/benchmarks/harness.mjs @@ -0,0 +1,85 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, readdir, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export function parseArgs(argv, defaults) { + const out = { ...defaults }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) throw new Error(`unexpected argument ${arg}`); + const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + if (!(key in out)) throw new Error(`unknown option ${arg}`); + const value = argv[i + 1]; + if (value === undefined || value.startsWith('--')) throw new Error(`${arg} needs a value`); + out[key] = typeof out[key] === 'number' ? Number(value) : value; + i += 1; + } + return out; +} + +/** + * Runs `requests` sequential requests `runs` times and keeps the best run. + * Sequential traffic over one warm connection isolates per-request client + * overhead, which is what the comparison is about; best-of-N absorbs scheduler + * noise without hiding a client that is genuinely slow. + */ +export async function measure(request, { requests, runs, warmup }) { + for (let i = 0; i < warmup; i += 1) await request(); + + const rates = []; + for (let run = 0; run < runs; run += 1) { + const start = process.hrtime.bigint(); + for (let i = 0; i < requests; i += 1) await request(); + const elapsedNs = Number(process.hrtime.bigint() - start); + rates.push((requests * 1e9) / elapsedNs); + } + rates.sort((a, b) => a - b); + return { + rps: rates.at(-1), + rpsMedian: rates[Math.floor(rates.length / 2)], + rpsWorst: rates[0], + }; +} + +async function treeSize(dir) { + let total = 0; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) total += await treeSize(path); + else if (entry.isFile()) total += (await stat(path)).size; + } + return total; +} + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'], ...options }); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with ${code}: ${stderr.trim().slice(0, 500)}`)); + }); + }); +} + +/** Bytes a fresh `npm install ` drops on disk, transitive dependencies included. */ +export async function installSize(pkg, version) { + const dir = await mkdtemp(join(tmpdir(), 'impit-bench-size-')); + try { + await run('npm', [ + 'install', `${pkg}@${version}`, + '--prefix', dir, + '--no-save', '--no-audit', '--no-fund', '--loglevel', 'error', + ]); + return await treeSize(join(dir, 'node_modules')); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +export function formatMB(bytes) { + return `${(bytes / 1e6).toFixed(1)} MB`; +} diff --git a/benchmarks/node/bench.mjs b/benchmarks/node/bench.mjs new file mode 100644 index 00000000..97e47188 --- /dev/null +++ b/benchmarks/node/bench.mjs @@ -0,0 +1,270 @@ +import { spawn } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { arch, platform } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { installSize, measure, parseArgs } from '../harness.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +const CHROME_JA3 = '771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,' + + '0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0'; +const CHROME_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/131.0.0.0 Safari/537.36'; + +// Read off disk rather than through `require`: some of these packages have an +// `exports` map that hides ./package.json. +async function packageVersion(pkg) { + const manifest = await readFile(join(here, 'node_modules', pkg, 'package.json'), 'utf8'); + return JSON.parse(manifest).version; +} + +/** + * Number of distinct impersonation targets the public API accepts, with aliases + * that merely resolve to another target left out. `null` means the set is not + * enumerable through the public API and `note` has to explain why. + */ +const profileCounts = { + // The browser list is a TS union, so the shipped declaration file is the only + // machine-readable form of it. `chrome`/`firefox`/`okhttp` are aliases for the + // newest version of their family. + async impit() { + const dts = await readFile(join(here, 'node_modules/impit/index.d.ts'), 'utf8'); + const union = /export type Browser =([^;]+);/.exec(dts); + if (!union) throw new Error('could not find the Browser union in impit/index.d.ts'); + const names = [...union[1].matchAll(/'([^']+)'/g)].map((m) => m[1]); + if (names.length === 0) throw new Error('impit Browser union parsed as empty'); + return names.filter((name) => /\d/.test(name)).length; + }, + nodeTlsClient() { + return Object.keys(require('node-tls-client').ClientIdentifier).length; + }, +}; + +const CLIENTS = [ + { + key: 'impit', + label: 'impit', + repo: null, + backend: 'Rust', + profiles: profileCounts.impit, + async setup(url) { + const { Impit } = await import('impit'); + const client = new Impit({ browser: 'chrome', ignoreTlsErrors: true }); + return { + request: async () => { + const response = await client.fetch(url); + return { body: await response.text(), alpn: response.headers.get('x-alpn') }; + }, + }; + }, + }, + { + key: 'got-scraping', + label: 'got-scraping', + repo: 'https://github.com/apify/got-scraping', + backend: 'Node.js TLS', + // `knownCiphers` in got-scraping's bundle is module-private: chrome, firefox + // and safari. Nothing exposes it at runtime, so it cannot be derived. + profiles: () => 3, + note: '`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.', + async setup(url) { + const { gotScraping } = await import('got-scraping'); + const client = gotScraping.extend({ + https: { rejectUnauthorized: false }, + retry: { limit: 0 }, + headerGeneratorOptions: { browsers: [{ name: 'chrome' }] }, + }); + return { + request: async () => { + const response = await client(url); + return { body: response.body, alpn: response.headers['x-alpn'] }; + }, + }; + }, + }, + { + key: 'node-tls-client', + label: 'node-tls-client', + repo: 'https://github.com/Sahil1337/node-tls-client', + backend: 'Go', + profiles: profileCounts.nodeTlsClient, + async setup(url) { + const { ClientIdentifier, Session, destroyTLS, initTLS } = await import('node-tls-client'); + await initTLS(); + const session = new Session({ + clientIdentifier: ClientIdentifier.chrome_131, + insecureSkipVerify: true, + }); + return { + request: async () => { + const response = await session.get(url); + return { body: await response.text(), alpn: response.headers['X-Alpn']?.[0] }; + }, + teardown: async () => { + await session.close(); + await destroyTLS(); + }, + }; + }, + }, + { + key: 'cycletls', + label: 'cycletls', + repo: 'https://github.com/Danny-Dasilva/CycleTLS', + backend: 'Go subprocess', + // CycleTLS takes a raw JA3 string rather than named profiles. + profiles: () => null, + note: '`cycletls` is configured with a raw JA3 string instead of named profiles, so it has no ' + + 'fixed set to count.', + async setup(url) { + const initCycleTLS = (await import('cycletls')).default; + const client = await initCycleTLS(); + return { + request: async () => { + const response = await client.get(url, { + ja3: CHROME_JA3, + userAgent: CHROME_UA, + insecureSkipVerify: true, + }); + return { body: await response.text(), alpn: response.headers['X-Alpn']?.[0] }; + }, + teardown: () => client.exit(), + }; + }, + }, + { + key: 'undici', + label: 'undici', + repo: null, + backend: 'Node.js', + baseline: true, + profiles: () => null, + async setup(url) { + const { Agent, request } = await import('undici'); + const dispatcher = new Agent({ connect: { rejectUnauthorized: false }, allowH2: true }); + return { + request: async () => { + const response = await request(url, { dispatcher }); + return { body: await response.body.text(), alpn: response.headers['x-alpn'] }; + }, + teardown: () => dispatcher.close(), + }; + }, + }, +]; + +function startServer() { + const child = spawn(process.execPath, [join(here, '..', 'server.mjs')], { + env: { ...process.env, PORT: '0' }, + stdio: ['ignore', 'pipe', 'inherit'], + }); + return new Promise((resolve, reject) => { + let buffered = ''; + child.stdout.on('data', (chunk) => { + buffered += chunk; + const newline = buffered.indexOf('\n'); + if (newline !== -1) resolve({ child, url: buffered.slice(0, newline) }); + }); + child.on('exit', (code) => reject(new Error(`server exited with ${code} before listening`))); + }); +} + +const options = parseArgs(process.argv.slice(2), { + requests: 2000, + runs: 11, + warmup: 200, + bodyBytes: 1024, + out: join(here, '..', 'results-node.json'), + only: '', +}); + +const selected = options.only + ? CLIENTS.filter((client) => options.only.split(',').includes(client.key)) + : CLIENTS; +if (selected.length === 0) throw new Error(`--only matched no client: ${options.only}`); + +const { child, url } = await startServer(); +const results = []; +const failures = []; + +// One long-lived dispatcher, so the stats connection is opened once and does not +// show up in any client's connection count. +const { Agent, request } = await import('undici'); +const statsDispatcher = new Agent({ connect: { rejectUnauthorized: false } }); +const readStats = async () => { + const response = await request(new URL('/__stats', url), { dispatcher: statsDispatcher }); + return response.body.json(); +}; +await readStats(); + +try { + for (const client of selected) { + process.stderr.write(`${client.key}: `); + let teardown; + try { + const setup = await client.setup(url); + teardown = setup.teardown; + + const probe = await setup.request(); + if (probe.body.length !== options.bodyBytes) { + throw new Error(`expected a ${options.bodyBytes} byte body, got ${probe.body.length}`); + } + + const before = await readStats(); + const timings = await measure(setup.request, options); + const after = await readStats(); + + const version = await packageVersion(client.key); + results.push({ + key: client.key, + label: client.label, + repo: client.repo, + backend: client.backend, + baseline: client.baseline ?? false, + version, + alpn: probe.alpn ?? null, + profiles: await client.profiles(), + note: client.note ?? null, + sizeBytes: await installSize(client.key, version), + connections: after.connections - before.connections, + ...timings, + }); + const { rps, connections } = results.at(-1); + process.stderr.write(`${rps.toFixed(0)} req/s over ${probe.alpn}, ${connections} connection(s)\n`); + } catch (error) { + failures.push(`${client.key}: ${error.message}`); + process.stderr.write(`FAILED (${error.message})\n`); + } finally { + await teardown?.(); + } + } +} finally { + await statsDispatcher.close(); + child.kill(); +} + +await writeFile(options.out, `${JSON.stringify({ + ecosystem: 'node', + runtime: `Node.js ${process.version}`, + platform: `${platform()}-${arch()}`, + measuredAt: new Date().toISOString(), + options: { + requests: options.requests, + runs: options.runs, + warmup: options.warmup, + bodyBytes: options.bodyBytes, + }, + results, +}, null, 2)}\n`); + +process.stderr.write(`wrote ${options.out}\n`); +if (failures.length > 0) { + process.stderr.write(`${failures.length} client(s) failed:\n${failures.join('\n')}\n`); + process.exitCode = 1; +} diff --git a/benchmarks/node/package.json b/benchmarks/node/package.json new file mode 100644 index 00000000..e1f163dc --- /dev/null +++ b/benchmarks/node/package.json @@ -0,0 +1,16 @@ +{ + "name": "impit-comparison-benchmark", + "private": true, + "type": "module", + "description": "Throughput comparison of Node.js browser-impersonation HTTP clients. Deliberately unpinned so every run resolves the latest published versions.", + "scripts": { + "bench": "node bench.mjs" + }, + "dependencies": { + "cycletls": "latest", + "got-scraping": "latest", + "impit": "latest", + "node-tls-client": "latest", + "undici": "latest" + } +} diff --git a/benchmarks/python/bench.py b/benchmarks/python/bench.py new file mode 100644 index 00000000..6659af62 --- /dev/null +++ b/benchmarks/python/bench.py @@ -0,0 +1,360 @@ +"""Throughput comparison of Python browser-impersonation HTTP clients. + +Runs every client against the shared local HTTP/2 origin in ../server.mjs and +writes ../results-python.json. See ../README.md for how the numbers are taken. +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import tempfile +import time +import typing +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from datetime import datetime, timezone +from importlib.metadata import version as installed_version +from pathlib import Path +from typing import Any + +HERE = Path(__file__).resolve().parent +SERVER = HERE.parent / 'server.mjs' + +# Node's `os.arch()` vocabulary, so both reports name the same machine the same way. +ARCH_ALIASES = {'x86_64': 'x64', 'AMD64': 'x64', 'aarch64': 'arm64'} + +CHROME_PROFILES = { + 'impit': 'chrome', + 'primp': 'chrome_146', + 'tls_client': 'chrome_131', + 'curl_cffi': 'chrome', +} + + +@dataclass +class Client: + key: str + """Distribution name on PyPI.""" + label: str + backend: str + setup: Callable[[str], tuple[Callable[[], tuple[bytes, str | None]], Callable[[], None] | None]] + profiles: Callable[[], int | None] + repo: str | None = None + baseline: bool = False + note: str | None = None + + +def _versioned(names: Iterable[str]) -> list[str]: + """Drop unversioned aliases such as impit's `chrome`, which means "newest Chrome".""" + return [name for name in names if re.search(r'\d', name)] + + +def impit_profiles() -> int: + import impit + + return len(_versioned(typing.get_args(impit.Browser))) + + +def rnet_profiles() -> int: + import rnet + + return len([name for name in dir(rnet.Impersonate) if not name.startswith('_')]) + + +def tls_client_profiles() -> int: + from tls_client.settings import ClientIdentifiers + + # Every identifier is a distinct target; the unversioned ones are app + # fingerprints (nike, zalando, ...), not aliases. + return len(typing.get_args(ClientIdentifiers)) + + +def curl_cffi_profiles() -> int: + from curl_cffi.requests import impersonate as ci + + names = set(typing.get_args(ci.BrowserTypeLiteral)) + # REAL_TARGET_MAP holds the "newest of this family" aliases. + names -= set(ci.REAL_TARGET_MAP) + # curl_cffi also kept the pre-rename spellings ("safari18_0") alongside the + # current ones ("safari180"); un-underscoring the version reaches the target. + deduped = {re.sub(r'(\d)_(\d)', r'\1\2', name) for name in names} + return len(names & deduped) + + +def setup_impit(url: str): + import impit + + client = impit.Client(browser=CHROME_PROFILES['impit'], verify=False) + + def request(): + response = client.get(url) + return response.content, response.headers.get('x-alpn') + + return request, None + + +def setup_rnet(url: str): + import rnet + + # rnet has no unversioned alias, so pick the newest Chrome it ships. + newest_chrome = max( + (name for name in dir(rnet.Impersonate) if name.startswith('Chrome')), + key=lambda name: int(name.removeprefix('Chrome')), + ) + client = rnet.BlockingClient(impersonate=getattr(rnet.Impersonate, newest_chrome), verify=False) + + def request(): + response = client.get(url) + alpn = response.headers.get('x-alpn') + return response.bytes(), alpn.decode() if isinstance(alpn, bytes) else alpn + + return request, None + + +def setup_primp(url: str): + import primp + + client = primp.Client(impersonate=CHROME_PROFILES['primp'], verify=False) + if client.impersonate != CHROME_PROFILES['primp']: + raise RuntimeError(f'primp fell back to {client.impersonate!r}; the profile name needs updating') + + def request(): + response = client.get(url) + return response.content, response.headers.get('x-alpn') + + return request, None + + +def setup_tls_client(url: str): + import tls_client + + session = tls_client.Session(client_identifier=CHROME_PROFILES['tls_client']) + + def request(): + response = session.get(url, insecure_skip_verify=True) + return response.content, response.headers.get('X-Alpn') + + return request, session.close + + +def setup_curl_cffi(url: str): + from curl_cffi import requests + + session = requests.Session(impersonate=CHROME_PROFILES['curl_cffi'], verify=False) + + def request(): + response = session.get(url) + return response.content, response.headers.get('x-alpn') + + return request, session.close + + +def setup_httpx(url: str): + import httpx + + client = httpx.Client(verify=False, http2=True) + + def request(): + response = client.get(url) + return response.content, response.headers.get('x-alpn') + + return request, client.close + + +CLIENTS = [ + Client( + key='rnet', + label='rnet', + repo='https://github.com/0x676e67/rnet', + backend='Rust', + setup=setup_rnet, + profiles=rnet_profiles, + ), + Client( + key='primp', + label='primp', + repo='https://github.com/deedy5/primp', + backend='Rust', + setup=setup_primp, + profiles=lambda: None, + note='`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.', + ), + Client( + key='impit', + label='impit', + backend='Rust', + setup=setup_impit, + profiles=impit_profiles, + ), + Client( + key='tls-client', + label='tls-client', + repo='https://github.com/FlorianREGAZ/Python-Tls-Client', + backend='Go', + setup=setup_tls_client, + profiles=tls_client_profiles, + ), + Client( + key='curl_cffi', + label='curl_cffi', + repo='https://github.com/lexiforest/curl_cffi', + backend='C (libcurl)', + setup=setup_curl_cffi, + profiles=curl_cffi_profiles, + ), + Client( + key='httpx', + label='httpx', + backend='Python', + baseline=True, + setup=setup_httpx, + profiles=lambda: None, + ), +] + + +def start_server() -> tuple[subprocess.Popen, str]: + node = shutil.which('node') + if node is None: + raise RuntimeError('node is needed to run the benchmark origin server') + process = subprocess.Popen( + [node, str(SERVER)], + env={**os.environ, 'PORT': '0'}, + stdout=subprocess.PIPE, + text=True, + ) + url = process.stdout.readline().strip() + if not url: + process.kill() + raise RuntimeError('the origin server exited before it printed its URL') + return process, url + + +def wheel_size(pkg: str, version: str) -> int: + """Size of the wheel pip installs for this interpreter and platform.""" + with tempfile.TemporaryDirectory() as target: + subprocess.run( + [sys.executable, '-m', 'pip', 'download', '--no-deps', '--only-binary', ':all:', + '--quiet', '--dest', target, f'{pkg}=={version}'], + check=True, + capture_output=True, + ) + wheels = list(Path(target).glob('*.whl')) + if len(wheels) != 1: + raise RuntimeError(f'expected one wheel for {pkg}, got {[w.name for w in wheels]}') + return wheels[0].stat().st_size + + +def measure(request, *, requests: int, runs: int, warmup: int) -> dict[str, float]: + """Best of `runs` batches of `requests` sequential calls; see ../harness.mjs for the rationale.""" + for _ in range(warmup): + request() + + rates = [] + for _ in range(runs): + started = time.perf_counter() + for _ in range(requests): + request() + rates.append(requests / (time.perf_counter() - started)) + rates.sort() + return {'rps': rates[-1], 'rpsMedian': rates[len(rates) // 2], 'rpsWorst': rates[0]} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--requests', type=int, default=2000) + parser.add_argument('--runs', type=int, default=11) + parser.add_argument('--warmup', type=int, default=200) + parser.add_argument('--body-bytes', type=int, default=1024) + parser.add_argument('--out', type=Path, default=HERE.parent / 'results-python.json') + parser.add_argument('--only', default='') + args = parser.parse_args() + + selected = [c for c in CLIENTS if not args.only or c.key in args.only.split(',')] + if not selected: + raise SystemExit(f'--only matched no client: {args.only}') + + import httpx + + process, url = start_server() + stats_client = httpx.Client(verify=False) + read_stats = lambda: stats_client.get(f'{url}__stats').json() # noqa: E731 + read_stats() + + results: list[dict[str, Any]] = [] + failures: list[str] = [] + try: + for client in selected: + print(f'{client.key}: ', end='', flush=True, file=sys.stderr) + teardown = None + try: + request, teardown = client.setup(url) + body, alpn = request() + if len(body) != args.body_bytes: + raise RuntimeError(f'expected a {args.body_bytes} byte body, got {len(body)}') + + before = read_stats() + timings = measure(request, requests=args.requests, runs=args.runs, warmup=args.warmup) + after = read_stats() + + version = installed_version(client.key) + results.append({ + 'key': client.key, + 'label': client.label, + 'repo': client.repo, + 'backend': client.backend, + 'baseline': client.baseline, + 'version': version, + 'alpn': alpn, + 'profiles': client.profiles(), + 'note': client.note, + 'sizeBytes': wheel_size(client.key, version), + 'connections': after['connections'] - before['connections'], + **timings, + }) + print( + f'{results[-1]["rps"]:.0f} req/s over {alpn}, ' + f'{results[-1]["connections"]} connection(s)', + file=sys.stderr, + ) + except Exception as exc: # noqa: BLE001 + failures.append(f'{client.key}: {exc}') + print(f'FAILED ({exc})', file=sys.stderr) + finally: + if teardown is not None: + teardown() + finally: + stats_client.close() + process.kill() + + args.out.write_text(json.dumps({ + 'ecosystem': 'python', + 'runtime': f'CPython {platform.python_version()}', + 'platform': f'{sys.platform}-{ARCH_ALIASES.get(platform.machine(), platform.machine())}', + 'measuredAt': datetime.now(timezone.utc).isoformat(), + 'options': { + 'requests': args.requests, + 'runs': args.runs, + 'warmup': args.warmup, + 'bodyBytes': args.body_bytes, + }, + 'results': results, + }, indent=2) + '\n') + + print(f'wrote {args.out}', file=sys.stderr) + if failures: + print(f'{len(failures)} client(s) failed:', *failures, sep='\n', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/benchmarks/python/requirements.txt b/benchmarks/python/requirements.txt new file mode 100644 index 00000000..2859871a --- /dev/null +++ b/benchmarks/python/requirements.txt @@ -0,0 +1,7 @@ +# Deliberately unpinned: every run resolves the latest published versions. +curl_cffi +httpx[http2] +impit +primp +rnet +tls-client diff --git a/benchmarks/server.mjs b/benchmarks/server.mjs new file mode 100644 index 00000000..f13889ce --- /dev/null +++ b/benchmarks/server.mjs @@ -0,0 +1,96 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { createSecureServer } from 'node:http2'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const certDir = join(here, '.cert'); +const keyPath = join(certDir, 'key.pem'); +const certPath = join(certDir, 'cert.pem'); + +function ensureCert() { + if (existsSync(keyPath) && existsSync(certPath)) return; + mkdirSync(certDir, { recursive: true }); + execFileSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-sha256', + '-days', '365', + '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', + '-keyout', keyPath, + '-out', certPath, + ], { stdio: 'ignore' }); +} + +// Precomputed so the server does no per-request work beyond the write. +function jsonBody(bytes) { + const envelope = Buffer.byteLength('{"filler":""}'); + return Buffer.from(`{"filler":"${'x'.repeat(bytes - envelope)}"}`); +} + +export const BODY_BYTES = 1024; + +export function startServer({ port = 0, bodyBytes = BODY_BYTES } = {}) { + ensureCert(); + const body = jsonBody(bodyBytes); + const server = createSecureServer({ + key: readFileSync(keyPath), + cert: readFileSync(certPath), + allowHTTP1: true, + ALPNProtocols: ['h2', 'http/1.1'], + // Clients that RST_STREAM every response once they have read it (got's + // http2-wrapper does) exhaust Node's Rapid-Reset budget after ~1000 + // requests and get a GOAWAY mid-run. The mitigation is right for a public + // origin and wrong for a throughput benchmark, where the server must never + // be the thing that rate-limits the client. + streamResetBurst: Number.MAX_SAFE_INTEGER, + streamResetRate: Number.MAX_SAFE_INTEGER, + }); + + // `GET /__stats` lets the benchmark check that a client really did keep one + // connection warm for a whole run instead of reconnecting per request. Only + // connections that carried benchmark traffic are counted, so the benchmark's + // own polling of this endpoint never shows up in a client's total. + const stats = { connections: 0, requests: 0 }; + const counted = new WeakSet(); + + server.on('request', (req, res) => { + if (req.url === '/__stats') { + const json = Buffer.from(JSON.stringify(stats)); + res.writeHead(200, { 'content-type': 'application/json', 'content-length': json.length }); + res.end(json); + return; + } + + const connection = req.stream?.session ?? req.socket; + if (!counted.has(connection)) { + counted.add(connection); + stats.connections += 1; + } + + stats.requests += 1; + res.writeHead(200, { + 'content-type': 'application/json', + 'content-length': body.length, + 'x-alpn': req.httpVersion === '2.0' ? 'h2' : `http/${req.httpVersion}`, + }); + res.end(body); + }); + + // A client tearing its connection down at the end of a run is normal here and + // must not take the server with it. + server.on('session', (session) => session.on('error', () => {})); + server.on('clientError', () => {}); + server.on('sessionError', () => {}); + + return new Promise((resolve) => { + server.listen(port, '127.0.0.1', () => { + resolve({ server, url: `https://localhost:${server.address().port}/`, certPath }); + }); + }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const { url } = await startServer({ port: Number(process.env.PORT ?? 8443) }); + process.stdout.write(`${url}\n`); +} diff --git a/benchmarks/update-readme.mjs b/benchmarks/update-readme.mjs new file mode 100644 index 00000000..bf76d1cd --- /dev/null +++ b/benchmarks/update-readme.mjs @@ -0,0 +1,126 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { formatMB, parseArgs } from './harness.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); + +const START = ''; +const END = ''; + +const options = parseArgs(process.argv.slice(2), { + readme: join(here, '..', 'README.md'), + node: join(here, 'results-node.json'), + python: join(here, 'results-python.json'), +}); + +async function load(path) { + const report = JSON.parse(await readFile(path, 'utf8')); + if (report.results.length === 0) throw new Error(`${path} contains no results`); + return report; +} + +const [python, node] = await Promise.all([load(options.python), load(options.node)]); + +// The caption speaks for both tables at once, so it may only be written when +// the two reports really are comparable. +if (JSON.stringify(python.options) !== JSON.stringify(node.options)) { + throw new Error('the two reports were taken with different parameters; rerun both'); +} + +/** Footnote markers are assigned in the order the tables reference them. */ +const footnotes = []; +function footnote(text) { + const existing = footnotes.indexOf(text); + return `[^${(existing === -1 ? footnotes.push(text) : existing + 1)}]`; +} + +const dominantAlpn = [...python.results, ...node.results] + .map((result) => result.alpn) + .reduce((agreed, alpn) => (agreed === alpn ? agreed : null)); + +/** Notes about how the throughput figure was reached, rendered next to it. */ +function throughputNotes(result, report) { + const notes = []; + if (result.alpn !== dominantAlpn) { + notes.push(footnote(`\`${result.label}\` negotiated ${result.alpn} rather than ${dominantAlpn}.`)); + } + const total = report.options.runs * report.options.requests; + if (result.connections >= total / 2) { + notes.push(footnote(`\`${result.label}\` opens a new connection for every request, so its figure ` + + 'includes a TLS handshake each time instead of reusing a warm one.')); + } else if (result.connections > report.options.runs) { + notes.push(footnote(`\`${result.label}\` reconnected ${result.connections} times mid-run.`)); + } + return notes.join(''); +} + +function table(report, sizeHeading) { + const ordered = [...report.results].sort((a, b) => (a.baseline - b.baseline) || (b.rps - a.rps)); + const rows = ordered.map((result) => { + const name = result.repo ? `[\`${result.label}\`](${result.repo})` : `\`${result.label}\``; + return [ + result.baseline ? `${name} (no impersonation)` : (result.repo ? name : `**${name}**`), + `${result.rps.toFixed(0)}${throughputNotes(result, report)}`, + formatMB(result.sizeBytes), + `${result.profiles ?? '—'}${result.note ? footnote(result.note) : ''}`, + result.backend, + ]; + }); + return [ + `| Package | req/s | ${sizeHeading} | Profiles | Backend |`, + '| --- | --- | --- | --- | --- |', + ...rows.map((cells) => `| ${cells.join(' | ')} |`), + ].join('\n'); +} + +const { requests, runs, bodyBytes } = python.options; +const caption = [ + `Sequential requests from a single client against the local HTTP/2 origin in [\`benchmarks/\`](benchmarks),`, + `${bodyBytes / 1024} KiB JSON response, best of ${runs} runs of ${requests} requests.`, + dominantAlpn ? `Every client negotiated ${dominantAlpn}.` : '', + '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 ` leaves on disk, transitive dependencies included.', +].filter(Boolean).join(' '); + +const platforms = [...new Set([python.platform, node.platform])].join(' / '); +const provenance = `Measured on ${platforms} with ${python.runtime} and ${node.runtime}` + + ` on ${python.measuredAt.slice(0, 10)}. Hardware moves these numbers around, so rerun` + + ' `benchmarks/` yourself before drawing conclusions.'; + +const body = [ + '### Comparison', + '', + caption, + '', + '**Python**', + '', + table(python, 'Wheel'), + '', + '**Node.js**', + '', + table(node, 'Install'), + '', + provenance, + ...(footnotes.length > 0 + ? ['', ...footnotes.map((text, index) => `[^${index + 1}]: ${text}`)] + : []), +].join('\n'); + +const readme = await readFile(options.readme, 'utf8'); +const start = readme.indexOf(START); +const end = readme.indexOf(END); +if (start === -1 || end === -1) { + throw new Error(`${options.readme} is missing the ${START} / ${END} markers`); +} + +const updated = `${readme.slice(0, start + START.length)}\n${body}\n${readme.slice(end)}`; +if (updated === readme) { + process.stderr.write('README is already up to date\n'); +} else { + await writeFile(options.readme, updated); + process.stderr.write(`updated ${options.readme}\n`); +} From 6f85ea48626a28309e6aa87bb42055be715d62f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Mon, 31 Aug 2026 15:25:48 +0200 Subject: [PATCH 2/9] 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. --- README.md | 32 +++++++++++++++++--------------- benchmarks/README.md | 10 ++++++---- benchmarks/harness.mjs | 9 +++++---- benchmarks/update-readme.mjs | 14 +++++++++++--- 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 5b7e472f..5575d4bd 100644 --- a/README.md +++ b/README.md @@ -34,35 +34,37 @@ async fn main() { ### Comparison -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 ` leaves on disk, transitive dependencies included. +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 ` leaves on disk, transitive dependencies included. **Python** | Package | req/s | Wheel | Profiles | Backend | | --- | --- | --- | --- | --- | -| [`primp`](https://github.com/deedy5/primp) | 2750 | 5.9 MB | —[^1] | Rust | -| [`rnet`](https://github.com/0x676e67/rnet) | 2735 | 3.7 MB | 75 | Rust | -| **`impit`** | 2000 | 4.2 MB | 20 | Rust | -| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 1338 | 41.3 MB | 51 | Go | -| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 1116 | 13.5 MB | 38 | C (libcurl) | -| `httpx` (no impersonation) | 797 | 0.1 MB | — | Python | +| [`primp`](https://github.com/deedy5/primp) | 6214 | 5.9 MB | —[^1] | Rust | +| [`rnet`](https://github.com/0x676e67/rnet) | 5104 | 3.7 MB | 75 | Rust | +| **`impit`** | 3780 | 4.2 MB | 20 | Rust | +| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 3420 | 13.5 MB | 38 | C (libcurl) | +| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 3211 | 41.3 MB | 51 | Go | +| `httpx` (no impersonation) | 2323 | 0.1 MB | — | Python | **Node.js** | Package | req/s | Install | Profiles | Backend | | --- | --- | --- | --- | --- | -| [`node-tls-client`](https://github.com/Sahil1337/node-tls-client) | 1566 | 30.7 MB | 63 | Go | -| **`impit`** | 997 | 8.7 MB | 20 | Rust | -| [`got-scraping`](https://github.com/apify/got-scraping) | 896 | 4.7 MB | 3[^2] | Node.js TLS | -| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 201[^3] | 133.0 MB | —[^4] | Go subprocess | -| `undici` (no impersonation) | 2010 | 1.9 MB | — | Node.js | +| **`impit`** | 2289 | 8.7 MB | 20 | Rust | +| [`got-scraping`](https://github.com/apify/got-scraping) | 2234 | 4.7 MB | 3[^2] | Node.js TLS | +| [`node-tls-client`](https://github.com/Sahil1337/node-tls-client) | 1659[^3] | 30.7 MB | 63 | Go | +| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 626[^4][^5] | 133.0 MB | —[^6] | Go subprocess | +| `undici` (no impersonation) | 5064 | 1.9 MB | — | Node.js | -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. +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. [^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. [^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. -[^3]: `cycletls` opens a new connection for every request, so its figure includes a TLS handshake each time instead of reusing a warm one. -[^4]: `cycletls` is configured with a raw JA3 string instead of named profiles, so it has no fixed set to count. +[^3]: `node-tls-client` was erratic across runs — 900 to 3095 req/s — so its median says less than the others'. +[^4]: `cycletls` was erratic across runs — 409 to 640 req/s — so its median says less than the others'. +[^5]: `cycletls` opens a new connection for every request, so its figure includes a TLS handshake each time instead of reusing a warm one. +[^6]: `cycletls` is configured with a raw JA3 string instead of named profiles, so it has no fixed set to count. ### Other projects diff --git a/benchmarks/README.md b/benchmarks/README.md index 8d166176..84ab4999 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -25,10 +25,12 @@ download` to size each wheel. [`server.mjs`](server.mjs) is the origin: a Node.js `http2` server on a self-signed certificate, serving a fixed 1 KiB JSON body over `h2` or `http/1.1`, whichever the client negotiates. Each -client then issues sequential requests over one connection; the best of N runs is reported, which -absorbs scheduler noise without flattering a client that is genuinely slow. Sequential single-client -traffic is deliberate — it isolates per-request client overhead, which is what differs between these -libraries, rather than measuring how well each one saturates a socket. +client then issues sequential requests over one connection, N runs of it, and the **median** run is +what the table quotes. Best and worst go into the result file too: some clients swing by 3x between +runs on the same machine, and quoting the best would reward them for one lucky pass — where that +spread is wide, `update-readme.mjs` footnotes it instead of pretending one number describes them. +Sequential single-client traffic is deliberate: it isolates per-request client overhead, which is +what differs between these libraries, rather than measuring how well each one saturates a socket. The server also reports its connection count at `/__stats`, and both scripts record how many connections a client opened while being measured. That is what surfaces `cycletls` handshaking on diff --git a/benchmarks/harness.mjs b/benchmarks/harness.mjs index dd43b7ba..f2410692 100644 --- a/benchmarks/harness.mjs +++ b/benchmarks/harness.mjs @@ -19,10 +19,11 @@ export function parseArgs(argv, defaults) { } /** - * Runs `requests` sequential requests `runs` times and keeps the best run. - * Sequential traffic over one warm connection isolates per-request client - * overhead, which is what the comparison is about; best-of-N absorbs scheduler - * noise without hiding a client that is genuinely slow. + * Runs `requests` sequential requests `runs` times over one warm connection, + * which isolates per-request client overhead — the thing that differs between + * these libraries. The median is what the table quotes; the best and worst runs + * come along because some clients swing by 3x between runs, and a best-of-N + * figure would quietly reward them for one lucky pass. */ export async function measure(request, { requests, runs, warmup }) { for (let i = 0; i < warmup; i += 1) await request(); diff --git a/benchmarks/update-readme.mjs b/benchmarks/update-readme.mjs index bf76d1cd..e99c330b 100644 --- a/benchmarks/update-readme.mjs +++ b/benchmarks/update-readme.mjs @@ -40,12 +40,19 @@ const dominantAlpn = [...python.results, ...node.results] .map((result) => result.alpn) .reduce((agreed, alpn) => (agreed === alpn ? agreed : null)); +/** Above this best-to-worst ratio a client's throughput is too unsteady to quote as one number. */ +const UNSTABLE_RATIO = 1.5; + /** Notes about how the throughput figure was reached, rendered next to it. */ function throughputNotes(result, report) { const notes = []; if (result.alpn !== dominantAlpn) { notes.push(footnote(`\`${result.label}\` negotiated ${result.alpn} rather than ${dominantAlpn}.`)); } + if (result.rps > result.rpsWorst * UNSTABLE_RATIO) { + notes.push(footnote(`\`${result.label}\` was erratic across runs — ${result.rpsWorst.toFixed(0)} to ` + + `${result.rps.toFixed(0)} req/s — so its median says less than the others'.`)); + } const total = report.options.runs * report.options.requests; if (result.connections >= total / 2) { notes.push(footnote(`\`${result.label}\` opens a new connection for every request, so its figure ` @@ -57,12 +64,13 @@ function throughputNotes(result, report) { } function table(report, sizeHeading) { - const ordered = [...report.results].sort((a, b) => (a.baseline - b.baseline) || (b.rps - a.rps)); + const ordered = [...report.results] + .sort((a, b) => (a.baseline - b.baseline) || (b.rpsMedian - a.rpsMedian)); const rows = ordered.map((result) => { const name = result.repo ? `[\`${result.label}\`](${result.repo})` : `\`${result.label}\``; return [ result.baseline ? `${name} (no impersonation)` : (result.repo ? name : `**${name}**`), - `${result.rps.toFixed(0)}${throughputNotes(result, report)}`, + `${result.rpsMedian.toFixed(0)}${throughputNotes(result, report)}`, formatMB(result.sizeBytes), `${result.profiles ?? '—'}${result.note ? footnote(result.note) : ''}`, result.backend, @@ -78,7 +86,7 @@ function table(report, sizeHeading) { const { requests, runs, bodyBytes } = python.options; const caption = [ `Sequential requests from a single client against the local HTTP/2 origin in [\`benchmarks/\`](benchmarks),`, - `${bodyBytes / 1024} KiB JSON response, best of ${runs} runs of ${requests} requests.`, + `${bodyBytes / 1024} KiB JSON response, median of ${runs} runs of ${requests} requests.`, dominantAlpn ? `Every client negotiated ${dominantAlpn}.` : '', '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', From 47a840af99c146aa942a0646ecc7fa47040034ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Mon, 31 Aug 2026 15:37:07 +0200 Subject: [PATCH 3/9] docs(benchmarks): the runs input reports the median, not the best run --- .github/workflows/comparison-benchmark.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/comparison-benchmark.yaml b/.github/workflows/comparison-benchmark.yaml index b866486d..350c6eda 100644 --- a/.github/workflows/comparison-benchmark.yaml +++ b/.github/workflows/comparison-benchmark.yaml @@ -10,7 +10,7 @@ on: description: Requests per run default: '2000' runs: - description: Runs per client, best one wins + description: Runs per client, the median is reported default: '11' # Exercise the harness whenever it changes, without proposing anything. pull_request: From 586f7c63ff411b64239b3ea5d8a3709bd1f867fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Mon, 31 Aug 2026 15:42:52 +0200 Subject: [PATCH 4/9] docs(benchmarks): drop the footnotes and trim the prose --- README.md | 19 ++++------ benchmarks/README.md | 69 ++++++++++++++++-------------------- benchmarks/harness.mjs | 8 ++--- benchmarks/node/bench.mjs | 18 +++++----- benchmarks/python/bench.py | 10 +++--- benchmarks/update-readme.mjs | 68 ++++++----------------------------- 6 files changed, 65 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index 5575d4bd..b2dd7c70 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ async fn main() { ### Comparison -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 ` leaves on disk, transitive dependencies included. +Median of 11 runs of 2000 sequential requests to a local HTTP/2 server, 1 KiB JSON responses over one warm connection. `Profiles` counts the impersonation targets each API exposes. **Python** | Package | req/s | Wheel | Profiles | Backend | | --- | --- | --- | --- | --- | -| [`primp`](https://github.com/deedy5/primp) | 6214 | 5.9 MB | —[^1] | Rust | +| [`primp`](https://github.com/deedy5/primp) | 6214 | 5.9 MB | n/a | Rust | | [`rnet`](https://github.com/0x676e67/rnet) | 5104 | 3.7 MB | 75 | Rust | | **`impit`** | 3780 | 4.2 MB | 20 | Rust | | [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 3420 | 13.5 MB | 38 | C (libcurl) | @@ -52,19 +52,12 @@ Sequential requests from a single client against the local HTTP/2 origin in [`be | Package | req/s | Install | Profiles | Backend | | --- | --- | --- | --- | --- | | **`impit`** | 2289 | 8.7 MB | 20 | Rust | -| [`got-scraping`](https://github.com/apify/got-scraping) | 2234 | 4.7 MB | 3[^2] | Node.js TLS | -| [`node-tls-client`](https://github.com/Sahil1337/node-tls-client) | 1659[^3] | 30.7 MB | 63 | Go | -| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 626[^4][^5] | 133.0 MB | —[^6] | Go subprocess | +| [`got-scraping`](https://github.com/apify/got-scraping) | 2234 | 4.7 MB | 3 | Node.js TLS | +| [`node-tls-client`](https://github.com/Sahil1337/node-tls-client) | 1659 | 30.7 MB | 63 | Go | +| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 626 | 133.0 MB | raw JA3 | Go subprocess | | `undici` (no impersonation) | 5064 | 1.9 MB | — | Node.js | -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. - -[^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. -[^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. -[^3]: `node-tls-client` was erratic across runs — 900 to 3095 req/s — so its median says less than the others'. -[^4]: `cycletls` was erratic across runs — 409 to 640 req/s — so its median says less than the others'. -[^5]: `cycletls` opens a new connection for every request, so its figure includes a TLS handshake each time instead of reusing a warm one. -[^6]: `cycletls` is configured with a raw JA3 string instead of named profiles, so it has no fixed set to count. +Measured by [`benchmarks/`](benchmarks) on linux-x64, 2026-08-31. Rerun it on your own hardware. ### Other projects diff --git a/benchmarks/README.md b/benchmarks/README.md index 84ab4999..22026650 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,8 +1,8 @@ # Comparison benchmark -Produces the comparison tables in the root [`README.md`](../README.md). Everything here measures the -**latest published** release of each client, `impit` included — nothing is pinned and no lockfile is -committed, so a run always reflects what the ecosystems ship today. +Generates the comparison tables in the root [`README.md`](../README.md). Nothing is pinned and no +lockfile is committed, so every run measures the latest published release of each client, `impit` +included. ## Running it @@ -16,47 +16,40 @@ python/.venv/bin/python python/bench.py # writes results-python.json node update-readme.mjs # rewrites the table in ../README.md ``` -`--requests`, `--runs` and `--warmup` shrink a run while iterating, and `--only impit,undici` limits -it to a few clients. `update-readme.mjs` refuses to run when the two reports disagree on the -parameters, so pass the same values to both. `uv venv --seed` matters: `bench.py` shells out to `pip -download` to size each wheel. +`--requests`, `--runs` and `--warmup` shrink a run while iterating; `--only impit,undici` limits it +to a few clients. Pass the same values to both scripts — `update-readme.mjs` rejects reports taken +with different parameters. `uv venv --seed` matters, because `bench.py` shells out to `pip download` +to size each wheel. + +Run the two scripts one after the other, never in parallel: they compete for the same cores and both +sets of numbers come out low. ## What is measured -[`server.mjs`](server.mjs) is the origin: a Node.js `http2` server on a self-signed certificate, -serving a fixed 1 KiB JSON body over `h2` or `http/1.1`, whichever the client negotiates. Each -client then issues sequential requests over one connection, N runs of it, and the **median** run is -what the table quotes. Best and worst go into the result file too: some clients swing by 3x between -runs on the same machine, and quoting the best would reward them for one lucky pass — where that -spread is wide, `update-readme.mjs` footnotes it instead of pretending one number describes them. -Sequential single-client traffic is deliberate: it isolates per-request client overhead, which is -what differs between these libraries, rather than measuring how well each one saturates a socket. - -The server also reports its connection count at `/__stats`, and both scripts record how many -connections a client opened while being measured. That is what surfaces `cycletls` handshaking on -every request rather than reusing a warm connection; without it the number would look like plain -per-request overhead. - -The `Profiles` column counts the distinct impersonation targets each public API accepts, minus -aliases that merely resolve to another target. There is no uniform way to ask for that, so every -client has its own small accessor in the two `bench.py`/`bench.mjs` client tables; where a library -does not expose the set at all, the count is dropped and a footnote says why. - -Sizes are the artifact each ecosystem actually distributes: for Python the platform wheel that `pip -download` picks, for Node.js what a fresh `npm install ` leaves on disk with its transitive -dependencies. +[`server.mjs`](server.mjs) is the origin — a Node.js `http2` server on a self-signed certificate +serving a fixed 1 KiB JSON body. Each client then issues sequential requests over one connection, N +times, and the median run is reported. Sequential single-client traffic isolates per-request client +overhead, which is what differs between these libraries. + +The result files also carry the best and worst run, the negotiated protocol, and how many +connections the client opened while being measured. That last one is worth checking when a number +looks off: `cycletls` opens a fresh connection per request, so its figure includes a handshake every +time. + +`Profiles` counts the impersonation targets each public API accepts, minus aliases that resolve to +another target. There is no uniform way to ask for that, so each client has its own accessor in the +`CLIENTS` table. + +Sizes are what each ecosystem distributes: the platform wheel for Python, and for Node.js whatever a +fresh `npm install` leaves on disk with its transitive dependencies. ## Notes on the origin -Node's HTTP/2 Rapid-Reset mitigation is switched off in `server.mjs`. Clients that `RST_STREAM` each -response once they have read it — got's `http2-wrapper` does — otherwise exhaust the default budget -of 1000 resets and are hit with a `GOAWAY` a thousand requests into a run. The mitigation is correct -for a public origin and wrong here, where the server must never be the thing that rate-limits the -client. +Node's HTTP/2 Rapid-Reset mitigation is off in `server.mjs`. Clients that `RST_STREAM` each response +once they have read it — got's `http2-wrapper` does — otherwise burn the default budget of 1000 +resets and take a `GOAWAY` mid-run. Right for a public origin, wrong for a benchmark. ## Adding a client -Add an entry to `CLIENTS` in the relevant script: how to build it, how to issue one request, how to -count its profiles, and a `note` if either the profile count or the throughput figure needs a caveat. -`update-readme.mjs` picks up the rest — ordering, footnote numbering and the caption — from the two -result files. +Add an entry to `CLIENTS`: how to build it, how to issue one request, and how to count its profiles. +`update-readme.mjs` takes care of ordering and the caption. diff --git a/benchmarks/harness.mjs b/benchmarks/harness.mjs index f2410692..8df089d0 100644 --- a/benchmarks/harness.mjs +++ b/benchmarks/harness.mjs @@ -19,11 +19,9 @@ export function parseArgs(argv, defaults) { } /** - * Runs `requests` sequential requests `runs` times over one warm connection, - * which isolates per-request client overhead — the thing that differs between - * these libraries. The median is what the table quotes; the best and worst runs - * come along because some clients swing by 3x between runs, and a best-of-N - * figure would quietly reward them for one lucky pass. + * `runs` batches of `requests` sequential requests over one warm connection. The + * median is what the table quotes; best and worst come along because some clients + * swing 3x between runs, which a best-of-N figure would flatter. */ export async function measure(request, { requests, runs, warmup }) { for (let i = 0; i < warmup; i += 1) await request(); diff --git a/benchmarks/node/bench.mjs b/benchmarks/node/bench.mjs index 97e47188..18d09d73 100644 --- a/benchmarks/node/bench.mjs +++ b/benchmarks/node/bench.mjs @@ -24,8 +24,8 @@ async function packageVersion(pkg) { /** * Number of distinct impersonation targets the public API accepts, with aliases - * that merely resolve to another target left out. `null` means the set is not - * enumerable through the public API and `note` has to explain why. + * that merely resolve to another target left out. `null` means there is no set to + * count, and the client supplies a `profilesLabel` for the cell instead. */ const profileCounts = { // The browser list is a TS union, so the shipped declaration file is the only @@ -68,11 +68,10 @@ const CLIENTS = [ repo: 'https://github.com/apify/got-scraping', backend: 'Node.js TLS', // `knownCiphers` in got-scraping's bundle is module-private: chrome, firefox - // and safari. Nothing exposes it at runtime, so it cannot be derived. + // and safari. Nothing exposes it at runtime, so it cannot be derived. They + // cover cipher and signature algorithm order only, not extension order, + // GREASE or HTTP/2 SETTINGS. profiles: () => 3, - note: '`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.', async setup(url) { const { gotScraping } = await import('got-scraping'); const client = gotScraping.extend({ @@ -118,10 +117,9 @@ const CLIENTS = [ label: 'cycletls', repo: 'https://github.com/Danny-Dasilva/CycleTLS', backend: 'Go subprocess', - // CycleTLS takes a raw JA3 string rather than named profiles. + // Configured with a raw JA3 string, so there is no fixed set to count. profiles: () => null, - note: '`cycletls` is configured with a raw JA3 string instead of named profiles, so it has no ' - + 'fixed set to count.', + profilesLabel: 'raw JA3', async setup(url) { const initCycleTLS = (await import('cycletls')).default; const client = await initCycleTLS(); @@ -230,7 +228,7 @@ try { version, alpn: probe.alpn ?? null, profiles: await client.profiles(), - note: client.note ?? null, + profilesLabel: client.profilesLabel ?? null, sizeBytes: await installSize(client.key, version), connections: after.connections - before.connections, ...timings, diff --git a/benchmarks/python/bench.py b/benchmarks/python/bench.py index 6659af62..d0759705 100644 --- a/benchmarks/python/bench.py +++ b/benchmarks/python/bench.py @@ -48,7 +48,8 @@ class Client: profiles: Callable[[], int | None] repo: str | None = None baseline: bool = False - note: str | None = None + profiles_label: str | None = None + """Shown in the Profiles cell when there is no set to count.""" def _versioned(names: Iterable[str]) -> list[str]: @@ -183,9 +184,10 @@ def request(): repo='https://github.com/deedy5/primp', backend='Rust', setup=setup_primp, + # primp does not expose its profile list, and an unknown name falls back to + # a random profile rather than erroring, so there is nothing to count. profiles=lambda: None, - note='`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.', + profiles_label='n/a', ), Client( key='impit', @@ -315,7 +317,7 @@ def main() -> int: 'version': version, 'alpn': alpn, 'profiles': client.profiles(), - 'note': client.note, + 'profilesLabel': client.profiles_label, 'sizeBytes': wheel_size(client.key, version), 'connections': after['connections'] - before['connections'], **timings, diff --git a/benchmarks/update-readme.mjs b/benchmarks/update-readme.mjs index e99c330b..2317032c 100644 --- a/benchmarks/update-readme.mjs +++ b/benchmarks/update-readme.mjs @@ -18,51 +18,19 @@ const options = parseArgs(process.argv.slice(2), { async function load(path) { const report = JSON.parse(await readFile(path, 'utf8')); if (report.results.length === 0) throw new Error(`${path} contains no results`); + // The caption speaks for every row, so a client that fell back to HTTP/1.1 has + // to stop the run rather than end up mislabelled. + const odd = report.results.find((result) => result.alpn !== 'h2'); + if (odd) throw new Error(`${odd.key} negotiated ${odd.alpn}, not h2`); return report; } const [python, node] = await Promise.all([load(options.python), load(options.node)]); -// The caption speaks for both tables at once, so it may only be written when -// the two reports really are comparable. if (JSON.stringify(python.options) !== JSON.stringify(node.options)) { throw new Error('the two reports were taken with different parameters; rerun both'); } -/** Footnote markers are assigned in the order the tables reference them. */ -const footnotes = []; -function footnote(text) { - const existing = footnotes.indexOf(text); - return `[^${(existing === -1 ? footnotes.push(text) : existing + 1)}]`; -} - -const dominantAlpn = [...python.results, ...node.results] - .map((result) => result.alpn) - .reduce((agreed, alpn) => (agreed === alpn ? agreed : null)); - -/** Above this best-to-worst ratio a client's throughput is too unsteady to quote as one number. */ -const UNSTABLE_RATIO = 1.5; - -/** Notes about how the throughput figure was reached, rendered next to it. */ -function throughputNotes(result, report) { - const notes = []; - if (result.alpn !== dominantAlpn) { - notes.push(footnote(`\`${result.label}\` negotiated ${result.alpn} rather than ${dominantAlpn}.`)); - } - if (result.rps > result.rpsWorst * UNSTABLE_RATIO) { - notes.push(footnote(`\`${result.label}\` was erratic across runs — ${result.rpsWorst.toFixed(0)} to ` - + `${result.rps.toFixed(0)} req/s — so its median says less than the others'.`)); - } - const total = report.options.runs * report.options.requests; - if (result.connections >= total / 2) { - notes.push(footnote(`\`${result.label}\` opens a new connection for every request, so its figure ` - + 'includes a TLS handshake each time instead of reusing a warm one.')); - } else if (result.connections > report.options.runs) { - notes.push(footnote(`\`${result.label}\` reconnected ${result.connections} times mid-run.`)); - } - return notes.join(''); -} - function table(report, sizeHeading) { const ordered = [...report.results] .sort((a, b) => (a.baseline - b.baseline) || (b.rpsMedian - a.rpsMedian)); @@ -70,9 +38,9 @@ function table(report, sizeHeading) { const name = result.repo ? `[\`${result.label}\`](${result.repo})` : `\`${result.label}\``; return [ result.baseline ? `${name} (no impersonation)` : (result.repo ? name : `**${name}**`), - `${result.rpsMedian.toFixed(0)}${throughputNotes(result, report)}`, + result.rpsMedian.toFixed(0), formatMB(result.sizeBytes), - `${result.profiles ?? '—'}${result.note ? footnote(result.note) : ''}`, + result.profiles ?? result.profilesLabel ?? '—', result.backend, ]; }); @@ -84,25 +52,13 @@ function table(report, sizeHeading) { } const { requests, runs, bodyBytes } = python.options; -const caption = [ - `Sequential requests from a single client against the local HTTP/2 origin in [\`benchmarks/\`](benchmarks),`, - `${bodyBytes / 1024} KiB JSON response, median of ${runs} runs of ${requests} requests.`, - dominantAlpn ? `Every client negotiated ${dominantAlpn}.` : '', - '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 ` leaves on disk, transitive dependencies included.', -].filter(Boolean).join(' '); - -const platforms = [...new Set([python.platform, node.platform])].join(' / '); -const provenance = `Measured on ${platforms} with ${python.runtime} and ${node.runtime}` - + ` on ${python.measuredAt.slice(0, 10)}. Hardware moves these numbers around, so rerun` - + ' `benchmarks/` yourself before drawing conclusions.'; const body = [ '### Comparison', '', - caption, + `Median of ${runs} runs of ${requests} sequential requests to a local HTTP/2 server, ` + + `${bodyBytes / 1024} KiB JSON responses over one warm connection. \`Profiles\` counts the ` + + 'impersonation targets each API exposes.', '', '**Python**', '', @@ -112,10 +68,8 @@ const body = [ '', table(node, 'Install'), '', - provenance, - ...(footnotes.length > 0 - ? ['', ...footnotes.map((text, index) => `[^${index + 1}]: ${text}`)] - : []), + `Measured by [\`benchmarks/\`](benchmarks) on ${node.platform}, ${python.measuredAt.slice(0, 10)}.` + + ' Rerun it on your own hardware.', ].join('\n'); const readme = await readFile(options.readme, 'utf8'); From 8f475a4eca81791005d66c5174d0b7d7f9e62da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Mon, 31 Aug 2026 15:44:12 +0200 Subject: [PATCH 5/9] docs(benchmarks): trim the code comments --- benchmarks/node/bench.mjs | 20 ++++++++------------ benchmarks/server.mjs | 16 +++++----------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/benchmarks/node/bench.mjs b/benchmarks/node/bench.mjs index 18d09d73..852c23f0 100644 --- a/benchmarks/node/bench.mjs +++ b/benchmarks/node/bench.mjs @@ -23,14 +23,13 @@ async function packageVersion(pkg) { } /** - * Number of distinct impersonation targets the public API accepts, with aliases - * that merely resolve to another target left out. `null` means there is no set to - * count, and the client supplies a `profilesLabel` for the cell instead. + * Impersonation targets the public API accepts, minus aliases that resolve to + * another target. `null` means there is no set to count and the client gives a + * `profilesLabel` instead. */ const profileCounts = { - // The browser list is a TS union, so the shipped declaration file is the only - // machine-readable form of it. `chrome`/`firefox`/`okhttp` are aliases for the - // newest version of their family. + // The browser list is a TS union, so the .d.ts is its only machine-readable + // form. `chrome`/`firefox`/`okhttp` alias the newest of their family. async impit() { const dts = await readFile(join(here, 'node_modules/impit/index.d.ts'), 'utf8'); const union = /export type Browser =([^;]+);/.exec(dts); @@ -67,10 +66,8 @@ const CLIENTS = [ label: 'got-scraping', repo: 'https://github.com/apify/got-scraping', backend: 'Node.js TLS', - // `knownCiphers` in got-scraping's bundle is module-private: chrome, firefox - // and safari. Nothing exposes it at runtime, so it cannot be derived. They - // cover cipher and signature algorithm order only, not extension order, - // GREASE or HTTP/2 SETTINGS. + // chrome, firefox and safari, from the module-private `knownCiphers` in + // got-scraping's bundle. Nothing exposes it at runtime. profiles: () => 3, async setup(url) { const { gotScraping } = await import('got-scraping'); @@ -191,8 +188,7 @@ const { child, url } = await startServer(); const results = []; const failures = []; -// One long-lived dispatcher, so the stats connection is opened once and does not -// show up in any client's connection count. +// One long-lived dispatcher keeps the stats connection out of every client's count. const { Agent, request } = await import('undici'); const statsDispatcher = new Agent({ connect: { rejectUnauthorized: false } }); const readStats = async () => { diff --git a/benchmarks/server.mjs b/benchmarks/server.mjs index f13889ce..b26d9386 100644 --- a/benchmarks/server.mjs +++ b/benchmarks/server.mjs @@ -38,19 +38,14 @@ export function startServer({ port = 0, bodyBytes = BODY_BYTES } = {}) { cert: readFileSync(certPath), allowHTTP1: true, ALPNProtocols: ['h2', 'http/1.1'], - // Clients that RST_STREAM every response once they have read it (got's - // http2-wrapper does) exhaust Node's Rapid-Reset budget after ~1000 - // requests and get a GOAWAY mid-run. The mitigation is right for a public - // origin and wrong for a throughput benchmark, where the server must never - // be the thing that rate-limits the client. + // Clients that RST_STREAM every response (got's http2-wrapper does) burn + // Node's Rapid-Reset budget after ~1000 requests and take a GOAWAY mid-run. streamResetBurst: Number.MAX_SAFE_INTEGER, streamResetRate: Number.MAX_SAFE_INTEGER, }); - // `GET /__stats` lets the benchmark check that a client really did keep one - // connection warm for a whole run instead of reconnecting per request. Only - // connections that carried benchmark traffic are counted, so the benchmark's - // own polling of this endpoint never shows up in a client's total. + // Only connections that carried benchmark traffic count, so the benchmark's own + // polling of /__stats stays out of a client's total. const stats = { connections: 0, requests: 0 }; const counted = new WeakSet(); @@ -77,8 +72,7 @@ export function startServer({ port = 0, bodyBytes = BODY_BYTES } = {}) { res.end(body); }); - // A client tearing its connection down at the end of a run is normal here and - // must not take the server with it. + // Clients drop their connection at the end of a run; that must not kill the server. server.on('session', (session) => session.on('error', () => {})); server.on('clientError', () => {}); server.on('sessionError', () => {}); From 2119d28ebbec1179652a88a392f691d6c912629b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Thu, 3 Sep 2026 10:46:02 +0200 Subject: [PATCH 6/9] fix(benchmarks): wire --body-bytes to the server and keep the token out of the measuring job --- .github/workflows/comparison-benchmark.yaml | 39 +++++++++++++++++---- benchmarks/README.md | 10 +++--- benchmarks/node/bench.mjs | 6 ++-- benchmarks/python/bench.py | 6 ++-- benchmarks/server.mjs | 5 ++- 5 files changed, 48 insertions(+), 18 deletions(-) diff --git a/.github/workflows/comparison-benchmark.yaml b/.github/workflows/comparison-benchmark.yaml index 350c6eda..9a955fdf 100644 --- a/.github/workflows/comparison-benchmark.yaml +++ b/.github/workflows/comparison-benchmark.yaml @@ -32,15 +32,18 @@ env: jobs: benchmark: - name: Measure and open a PR + name: Measure runs-on: ubuntu-latest timeout-minutes: 60 steps: + # No credentials in this job: it installs and executes unpinned releases of + # every client, so it must have nothing worth stealing. Reading a public + # repository needs no token. - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.event_name == 'pull_request' && github.sha || 'master' }} - token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN || github.token }} + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 @@ -69,18 +72,42 @@ jobs: - name: Rewrite the README table run: node benchmarks/update-readme.mjs - - name: Upload the raw measurements + - name: Upload the rewritten README and raw measurements uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: comparison-results - path: benchmarks/results-*.json + path: | + README.md + benchmarks/results-*.json + + # Separate runner, so the token never shares an environment with third-party + # code. This job installs nothing. + propose: + name: Open a pull request + needs: benchmark + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: master + token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} + + - name: Download the rewritten README + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: comparison-results - name: Open a pull request - if: github.event_name != 'pull_request' env: GH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} run: | + # Without this, an artifact that unpacked somewhere unexpected would + # look exactly like "the numbers did not move". + test -f benchmarks/results-node.json || { echo 'the artifact did not unpack where expected'; exit 1; } + if git diff --quiet -- README.md; then echo "The measurements did not move the table; nothing to propose." exit 0 @@ -95,7 +122,7 @@ jobs: if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then gh pr create --base master --head "$BRANCH" \ --title 'docs: refresh the client comparison benchmark' \ - --body "Measured by \`.github/workflows/comparison-benchmark.yaml\` against the local origin in \`benchmarks/\`, $RUNS runs of $REQUESTS requests per client. Raw numbers are attached to [the run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) as an artifact." + --body "Measured by \`.github/workflows/comparison-benchmark.yaml\`, $RUNS runs of $REQUESTS requests per client. Raw numbers are attached to [the run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})." else echo "The existing pull request now points at the new measurements." fi diff --git a/benchmarks/README.md b/benchmarks/README.md index 22026650..04dd9d6d 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -16,10 +16,10 @@ python/.venv/bin/python python/bench.py # writes results-python.json node update-readme.mjs # rewrites the table in ../README.md ``` -`--requests`, `--runs` and `--warmup` shrink a run while iterating; `--only impit,undici` limits it -to a few clients. Pass the same values to both scripts — `update-readme.mjs` rejects reports taken -with different parameters. `uv venv --seed` matters, because `bench.py` shells out to `pip download` -to size each wheel. +`--requests`, `--runs` and `--warmup` shrink a run while iterating, `--body-bytes` changes the +response size, and `--only impit,undici` limits it to a few clients. Pass the same values to both +scripts — `update-readme.mjs` rejects reports taken with different parameters. `uv venv --seed` +matters, because `bench.py` shells out to `pip download` to size each wheel. Run the two scripts one after the other, never in parallel: they compete for the same cores and both sets of numbers come out low. @@ -27,7 +27,7 @@ sets of numbers come out low. ## What is measured [`server.mjs`](server.mjs) is the origin — a Node.js `http2` server on a self-signed certificate -serving a fixed 1 KiB JSON body. Each client then issues sequential requests over one connection, N +serving a 1 KiB JSON body. Each client then issues sequential requests over one connection, N times, and the median run is reported. Sequential single-client traffic isolates per-request client overhead, which is what differs between these libraries. diff --git a/benchmarks/node/bench.mjs b/benchmarks/node/bench.mjs index 852c23f0..5c4f90f3 100644 --- a/benchmarks/node/bench.mjs +++ b/benchmarks/node/bench.mjs @@ -154,9 +154,9 @@ const CLIENTS = [ }, ]; -function startServer() { +function startServer(bodyBytes) { const child = spawn(process.execPath, [join(here, '..', 'server.mjs')], { - env: { ...process.env, PORT: '0' }, + env: { ...process.env, PORT: '0', BODY_BYTES: String(bodyBytes) }, stdio: ['ignore', 'pipe', 'inherit'], }); return new Promise((resolve, reject) => { @@ -184,7 +184,7 @@ const selected = options.only : CLIENTS; if (selected.length === 0) throw new Error(`--only matched no client: ${options.only}`); -const { child, url } = await startServer(); +const { child, url } = await startServer(options.bodyBytes); const results = []; const failures = []; diff --git a/benchmarks/python/bench.py b/benchmarks/python/bench.py index d0759705..bc534a1f 100644 --- a/benchmarks/python/bench.py +++ b/benchmarks/python/bench.py @@ -223,13 +223,13 @@ def request(): ] -def start_server() -> tuple[subprocess.Popen, str]: +def start_server(body_bytes: int) -> tuple[subprocess.Popen, str]: node = shutil.which('node') if node is None: raise RuntimeError('node is needed to run the benchmark origin server') process = subprocess.Popen( [node, str(SERVER)], - env={**os.environ, 'PORT': '0'}, + env={**os.environ, 'PORT': '0', 'BODY_BYTES': str(body_bytes)}, stdout=subprocess.PIPE, text=True, ) @@ -286,7 +286,7 @@ def main() -> int: import httpx - process, url = start_server() + process, url = start_server(args.body_bytes) stats_client = httpx.Client(verify=False) read_stats = lambda: stats_client.get(f'{url}__stats').json() # noqa: E731 read_stats() diff --git a/benchmarks/server.mjs b/benchmarks/server.mjs index b26d9386..db88345e 100644 --- a/benchmarks/server.mjs +++ b/benchmarks/server.mjs @@ -85,6 +85,9 @@ export function startServer({ port = 0, bodyBytes = BODY_BYTES } = {}) { } if (import.meta.url === `file://${process.argv[1]}`) { - const { url } = await startServer({ port: Number(process.env.PORT ?? 8443) }); + const { url } = await startServer({ + port: Number(process.env.PORT ?? 8443), + bodyBytes: Number(process.env.BODY_BYTES ?? BODY_BYTES), + }); process.stdout.write(`${url}\n`); } From 7a1fefda85d37191d139d8d1cdc37869636ac5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Thu, 3 Sep 2026 10:46:41 +0200 Subject: [PATCH 7/9] docs(benchmarks): drop a stale best-of-N mention --- benchmarks/python/bench.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/python/bench.py b/benchmarks/python/bench.py index bc534a1f..f1cf9e99 100644 --- a/benchmarks/python/bench.py +++ b/benchmarks/python/bench.py @@ -256,7 +256,7 @@ def wheel_size(pkg: str, version: str) -> int: def measure(request, *, requests: int, runs: int, warmup: int) -> dict[str, float]: - """Best of `runs` batches of `requests` sequential calls; see ../harness.mjs for the rationale.""" + """`runs` batches of `requests` sequential calls; see ../harness.mjs for the rationale.""" for _ in range(warmup): request() From 3cc1b76f47d2c9ad3b5296f6cf36d66bad4721d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Thu, 3 Sep 2026 10:53:18 +0200 Subject: [PATCH 8/9] feat(benchmarks): drop node-tls-client from the comparison It resolves the newest bogdanfinn/tls-client release at runtime and downloads tls-client-linux-ubuntu-amd64-{version}.so from it, but v1.16.0 ships only the xgo builds, so the asset it asks for no longer exists and a fresh install cannot start. Nothing on our side can pin it. --- README.md | 21 ++++++++++----------- benchmarks/node/bench.mjs | 30 ------------------------------ benchmarks/node/package.json | 1 - 3 files changed, 10 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b2dd7c70..6ac5cd06 100644 --- a/README.md +++ b/README.md @@ -40,22 +40,21 @@ Median of 11 runs of 2000 sequential requests to a local HTTP/2 server, 1 KiB JS | Package | req/s | Wheel | Profiles | Backend | | --- | --- | --- | --- | --- | -| [`primp`](https://github.com/deedy5/primp) | 6214 | 5.9 MB | n/a | Rust | -| [`rnet`](https://github.com/0x676e67/rnet) | 5104 | 3.7 MB | 75 | Rust | -| **`impit`** | 3780 | 4.2 MB | 20 | Rust | -| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 3420 | 13.5 MB | 38 | C (libcurl) | -| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 3211 | 41.3 MB | 51 | Go | -| `httpx` (no impersonation) | 2323 | 0.1 MB | — | Python | +| [`primp`](https://github.com/deedy5/primp) | 4013 | 5.9 MB | n/a | Rust | +| [`rnet`](https://github.com/0x676e67/rnet) | 3481 | 3.7 MB | 75 | Rust | +| **`impit`** | 2786 | 4.2 MB | 20 | Rust | +| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 2453 | 13.5 MB | 38 | C (libcurl) | +| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 2398 | 41.3 MB | 51 | Go | +| `httpx` (no impersonation) | 1607 | 0.1 MB | — | Python | **Node.js** | Package | req/s | Install | Profiles | Backend | | --- | --- | --- | --- | --- | -| **`impit`** | 2289 | 8.7 MB | 20 | Rust | -| [`got-scraping`](https://github.com/apify/got-scraping) | 2234 | 4.7 MB | 3 | Node.js TLS | -| [`node-tls-client`](https://github.com/Sahil1337/node-tls-client) | 1659 | 30.7 MB | 63 | Go | -| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 626 | 133.0 MB | raw JA3 | Go subprocess | -| `undici` (no impersonation) | 5064 | 1.9 MB | — | Node.js | +| [`got-scraping`](https://github.com/apify/got-scraping) | 1727 | 4.7 MB | 3 | Node.js TLS | +| **`impit`** | 1692 | 8.7 MB | 20 | Rust | +| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 420 | 133.0 MB | raw JA3 | Go subprocess | +| `undici` (no impersonation) | 3258 | 1.9 MB | — | Node.js | Measured by [`benchmarks/`](benchmarks) on linux-x64, 2026-08-31. Rerun it on your own hardware. diff --git a/benchmarks/node/bench.mjs b/benchmarks/node/bench.mjs index 5c4f90f3..28ce1a99 100644 --- a/benchmarks/node/bench.mjs +++ b/benchmarks/node/bench.mjs @@ -1,6 +1,5 @@ import { spawn } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; import { arch, platform } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,7 +7,6 @@ import { fileURLToPath } from 'node:url'; import { installSize, measure, parseArgs } from '../harness.mjs'; const here = dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); const CHROME_JA3 = '771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,' + '0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0'; @@ -38,9 +36,6 @@ const profileCounts = { if (names.length === 0) throw new Error('impit Browser union parsed as empty'); return names.filter((name) => /\d/.test(name)).length; }, - nodeTlsClient() { - return Object.keys(require('node-tls-client').ClientIdentifier).length; - }, }; const CLIENTS = [ @@ -84,31 +79,6 @@ const CLIENTS = [ }; }, }, - { - key: 'node-tls-client', - label: 'node-tls-client', - repo: 'https://github.com/Sahil1337/node-tls-client', - backend: 'Go', - profiles: profileCounts.nodeTlsClient, - async setup(url) { - const { ClientIdentifier, Session, destroyTLS, initTLS } = await import('node-tls-client'); - await initTLS(); - const session = new Session({ - clientIdentifier: ClientIdentifier.chrome_131, - insecureSkipVerify: true, - }); - return { - request: async () => { - const response = await session.get(url); - return { body: await response.text(), alpn: response.headers['X-Alpn']?.[0] }; - }, - teardown: async () => { - await session.close(); - await destroyTLS(); - }, - }; - }, - }, { key: 'cycletls', label: 'cycletls', diff --git a/benchmarks/node/package.json b/benchmarks/node/package.json index e1f163dc..d9f5e452 100644 --- a/benchmarks/node/package.json +++ b/benchmarks/node/package.json @@ -10,7 +10,6 @@ "cycletls": "latest", "got-scraping": "latest", "impit": "latest", - "node-tls-client": "latest", "undici": "latest" } } From 5f3bd1f5d1ac2254ac08d2a323ca9cc9c52c42d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Thu, 3 Sep 2026 10:56:42 +0200 Subject: [PATCH 9/9] docs(benchmarks): refresh the table from the green CI run --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6ac5cd06..28329f41 100644 --- a/README.md +++ b/README.md @@ -40,23 +40,23 @@ Median of 11 runs of 2000 sequential requests to a local HTTP/2 server, 1 KiB JS | Package | req/s | Wheel | Profiles | Backend | | --- | --- | --- | --- | --- | -| [`primp`](https://github.com/deedy5/primp) | 4013 | 5.9 MB | n/a | Rust | -| [`rnet`](https://github.com/0x676e67/rnet) | 3481 | 3.7 MB | 75 | Rust | -| **`impit`** | 2786 | 4.2 MB | 20 | Rust | -| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 2453 | 13.5 MB | 38 | C (libcurl) | -| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 2398 | 41.3 MB | 51 | Go | -| `httpx` (no impersonation) | 1607 | 0.1 MB | — | Python | +| [`primp`](https://github.com/deedy5/primp) | 6312 | 5.9 MB | n/a | Rust | +| [`rnet`](https://github.com/0x676e67/rnet) | 5093 | 3.7 MB | 75 | Rust | +| **`impit`** | 3808 | 4.2 MB | 20 | Rust | +| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 3428 | 13.5 MB | 38 | C (libcurl) | +| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 3223 | 41.3 MB | 51 | Go | +| `httpx` (no impersonation) | 2311 | 0.1 MB | — | Python | **Node.js** | Package | req/s | Install | Profiles | Backend | | --- | --- | --- | --- | --- | -| [`got-scraping`](https://github.com/apify/got-scraping) | 1727 | 4.7 MB | 3 | Node.js TLS | -| **`impit`** | 1692 | 8.7 MB | 20 | Rust | -| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 420 | 133.0 MB | raw JA3 | Go subprocess | -| `undici` (no impersonation) | 3258 | 1.9 MB | — | Node.js | +| [`got-scraping`](https://github.com/apify/got-scraping) | 2332 | 4.7 MB | 3 | Node.js TLS | +| **`impit`** | 2260 | 8.7 MB | 20 | Rust | +| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 610 | 133.0 MB | raw JA3 | Go subprocess | +| `undici` (no impersonation) | 4661 | 1.9 MB | — | Node.js | -Measured by [`benchmarks/`](benchmarks) on linux-x64, 2026-08-31. Rerun it on your own hardware. +Measured by [`benchmarks/`](benchmarks) on linux-x64, 2026-09-03. Rerun it on your own hardware. ### Other projects