From 8be3450a1a75f8eee60648dc28328fdc24c7486b Mon Sep 17 00:00:00 2001 From: BlankParticle Date: Fri, 14 Aug 2026 19:04:47 +0530 Subject: [PATCH 1/2] chore: add better benchmarks --- benchmarks/fetch/README.md | 90 +++-- benchmarks/fetch/scripts/bench.ts | 536 +++++++++++++--------------- benchmarks/fetch/src/app.js | 82 +++-- benchmarks/fetch/src/server-node.js | 61 ++++ 4 files changed, 417 insertions(+), 352 deletions(-) create mode 100644 benchmarks/fetch/src/server-node.js diff --git a/benchmarks/fetch/README.md b/benchmarks/fetch/README.md index a41936b..a1396ee 100644 --- a/benchmarks/fetch/README.md +++ b/benchmarks/fetch/README.md @@ -1,13 +1,13 @@ -# Benchmark +# Fetch server benchmark -Benchmark comparing the published npm version and local development version of @hono/node-server with srvx. +This suite measures the raw HTTP-to-Fetch adapter cost of the published and development versions of `@hono/node-server`. A native `node:http` implementation is the performance floor, while srvx with `FastResponse` is another Fetch-compatible adapter for comparison. -This benchmark uses a basic Fetch API-based application without the Hono framework to measure the raw performance of @hono/node-server's adapter. +The benchmark intentionally avoids Hono routing so application-framework work does not obscure adapter work. ## Prerequisites - Node.js -- [bombardier](https://github.com/codesenberg/bombardier) installation +- [oha](https://github.com/hatoo/oha) ## Usage @@ -17,45 +17,65 @@ pnpm run -w build pnpm run benchmark ``` -## What's Being Tested +To replace the results below after a full successful run: -Tests four endpoints: +```bash +pnpm run benchmark --update +``` -1. **Ping (GET /)**: Simple response -2. **Query (GET /id/:id)**: Path parameter and query parameter handling -3. **Body (POST /json)**: JSON body processing -4. **Headers (GET /headers)**: Isolated `request.headers.get()` access +Quick smoke run: -Each endpoint is tested with 500 concurrent connections for 10 seconds, measuring requests per second (Reqs/sec). +```bash +BENCH_CONNECTIONS=10 BENCH_WARMUP=1s BENCH_DURATION=1s BENCH_TRIES=1 pnpm run benchmark +``` -## Benchmark Environment +The defaults are 100 concurrent connections, a discarded two-second warmup, and the median of three five-second measurements. The server order is randomized to reduce systematic thermal or background-load bias. -- **Machine**: Lenovo LOQ 15IRX9 (83DV) -- **CPU**: Intel Core i5-13450HX (10 cores, 16 threads) -- **Memory**: 24 GB -- **OS**: Arch Linux x86_64 (kernel 7.1.6) -- **Node.js**: 24.19.0 +## Scenarios -## Understanding Results +The suite validates every response before measuring it, rejects load-generator errors or unexpected status classes, and reports request rate, mean latency, response throughput, and isolated server peak RSS. -Last updated: 2026-08-09 +It covers: -``` -| Benchmark | @hono/node-server (2.1.0) | srvx (0.12.5, fast) | @hono/node-server (dev) | dev vs npm | dev vs srvx | -| ----------------- | ------------------------- | ------------------- | ----------------------- | ---------- | ----------- | -| Average | 83,588.79 | 89,245.89 | 88,398.73 | +5.75% | -0.95% | -| Ping (GET /) | 87,502.45 | 97,875.62 | 96,320.69 | +10.08% | -1.59% | -| Query (GET /id) | 92,967.16 | 89,524.22 | 93,474.95 | +0.55% | +4.41% | -| Body (POST /json) | 72,621.78 | 75,968.80 | 73,823.52 | +1.65% | -2.82% | -| Headers (GET) | 81,263.78 | 93,614.90 | 89,975.77 | +10.72% | -3.89% | -``` +1. A bodyless `204` response to `HEAD` +2. A small text response +3. URL and query-string parsing +4. Incoming header reads and outgoing headers +5. JSON serialization +6. JSON request parsing and response serialization +7. A 64 KiB request body +8. A fixed 64 KiB response body +9. A chunked `ReadableStream` response -- **@hono/node-server (2.1.0)**: Published npm version -- **@hono/node-server (dev)**: Local development version (from repository root `dist/`) -- **srvx (0.12.5, fast)**: Published npm version using its opt-in `FastResponse` -- **dev vs npm**: Development Hono compared with published Hono -- **dev vs srvx**: Development Hono compared with srvx +These are synthetic microbenchmarks. They are useful for finding adapter regressions, not for predicting application throughput in production. -## Reference +## Credits + +The benchmark methodology and reporting are inspired by the [srvx Node.js compatibility benchmarks](https://github.com/h3js/srvx/tree/main/test/bench-node). The endpoint suite builds on ideas from [bun-http-framework-benchmark](https://github.com/SaltyAom/bun-http-framework-benchmark). + +## Results + + + +```text +CPU: 13th Gen Intel(R) Core(TM) i5-13450HX +Node.js: v24.19.0 +OS: linux x64 +OHA: oha 1.15.0 +Config: 100 connections, 2s warmup, 3 × 5s +``` -This benchmark setup is based on [bun-http-framework-benchmark](https://github.com/SaltyAom/bun-http-framework-benchmark) by @SaltyAom. +| Scenario | node:http | @hono/node-server (npm) | srvx (fast) | @hono/node-server (dev) | +| ----------------- | --------: | ----------------------: | --------------: | ----------------------: | +| empty response | 117,877 | 105,569 (-10.4%) | 108,464 (-8.0%) | 105,096 (-10.8%) | +| small text | 107,385 | 95,037 (-11.5%) | 94,733 (-11.8%) | 93,184 (-13.2%) | +| URL + query | 102,987 | 90,440 (-12.2%) | 90,044 (-12.6%) | 89,240 (-13.3%) | +| headers | 96,469 | 75,109 (-22.1%) | 87,595 (-9.2%) | 87,909 (-8.9%) | +| JSON response | 97,773 | 90,378 (-7.6%) | 91,174 (-6.8%) | 91,514 (-6.4%) | +| JSON round trip | 81,818 | 70,050 (-14.4%) | 74,833 (-8.5%) | 71,683 (-12.4%) | +| 64 KiB upload | 29,078 | 20,101 (-30.9%) | 12,812 (-55.9%) | 20,268 (-30.3%) | +| 64 KiB fixed body | 60,324 | 55,822 (-7.5%) | 56,836 (-5.8%) | 56,384 (-6.5%) | +| 64 KiB stream | 46,091 | 31,166 (-32.4%) | 33,847 (-26.6%) | 30,866 (-33.0%) | +| peak RSS (MiB) | 349.7 | 364.4 | 330.7 | 366.1 | + + diff --git a/benchmarks/fetch/scripts/bench.ts b/benchmarks/fetch/scripts/bench.ts index 54105d7..d3a2e16 100644 --- a/benchmarks/fetch/scripts/bench.ts +++ b/benchmarks/fetch/scripts/bench.ts @@ -1,326 +1,298 @@ -import { spawn } from 'node:child_process' +import assert from 'node:assert/strict' +import { spawn, spawnSync, type ChildProcess } from 'node:child_process' import { once } from 'node:events' +import { readFileSync, writeFileSync } from 'node:fs' +import { cpus } from 'node:os' import { setTimeout } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' const PORT = 3000 -const WARMUP_TIME = 1000 +const CONNECTIONS = Number(process.env.BENCH_CONNECTIONS ?? 100) +const DURATION = process.env.BENCH_DURATION ?? '5s' +const WARMUP_DURATION = process.env.BENCH_WARMUP ?? '2s' +const TRIES = Number(process.env.BENCH_TRIES ?? 3) +const LARGE_SIZE = 64 * 1024 + +interface Server { + file: string + name: string +} -interface BenchmarkResult { +interface Scenario { name: string - reqsPerSec: number + path: string + method?: string + headers?: string[] + body?: string + expectedStatus?: number + verify: (response: Response) => Promise +} + +interface Sample { + rps: number + latencyMs: number + throughputMb: number } interface ServerResult { - server: string - runtime: string - average: number - ping: number - query: number - body: number - headers: number + server: Server + peakRssMb: number | null + scenarios: Map } -async function waitForServer(): Promise { - const maxRetries = 30 - for (let i = 0; i < maxRetries; i++) { - try { - const response = await fetch(`http://localhost:${PORT}`) - if (response.ok) { - console.log('✓ Server is ready\n') - return - } - } catch (e) { - await setTimeout(100) - } +const jsonBody = JSON.stringify({ message: 'Hello!' }) +const uploadBody = 'x'.repeat(LARGE_SIZE) + +const scenarios: Scenario[] = [ + { + name: 'empty response', + path: '/empty', + method: 'HEAD', + expectedStatus: 204, + verify: async (response) => assert.equal(await response.text(), ''), + }, + { + name: 'small text', + path: '/', + verify: async (response) => assert.equal(await response.text(), 'Hi'), + }, + { + name: 'URL + query', + path: '/query?id=123&name=benchmark', + verify: async (response) => assert.equal(await response.text(), '123 benchmark'), + }, + { + name: 'headers', + path: '/headers', + headers: ['x-test: 123'], + verify: async (response) => { + assert.equal(await response.text(), '123') + assert.equal(response.headers.get('x-powered-by'), 'benchmark') + assert.equal(response.headers.get('cache-control'), 'public, max-age=60') + }, + }, + { + name: 'JSON response', + path: '/json', + verify: async (response) => + assert.deepEqual(await response.json(), { message: 'Hello!', ok: true }), + }, + { + name: 'JSON round trip', + path: '/json', + method: 'POST', + headers: ['content-type: application/json'], + body: jsonBody, + verify: async (response) => assert.deepEqual(await response.json(), { message: 'Hello!' }), + }, + { + name: '64 KiB upload', + path: '/upload', + method: 'POST', + headers: ['content-type: application/octet-stream'], + body: uploadBody, + verify: async (response) => assert.equal(await response.text(), String(LARGE_SIZE)), + }, + { + name: '64 KiB fixed body', + path: '/large', + verify: async (response) => assert.equal((await response.arrayBuffer()).byteLength, LARGE_SIZE), + }, + { + name: '64 KiB stream', + path: '/stream', + verify: async (response) => assert.equal((await response.arrayBuffer()).byteLength, LARGE_SIZE), + }, +] + +const servers: Server[] = [ + { file: 'src/server-node.js', name: 'node:http' }, + { file: 'src/server-npm.js', name: '@hono/node-server (npm)' }, + { file: 'src/server-srvx.js', name: 'srvx (fast)' }, + { file: 'src/server-dev.js', name: '@hono/node-server (dev)' }, +] + +function ohaVersion(): string { + const result = spawnSync('oha', ['--version'], { encoding: 'utf8' }) + if (result.error || result.status !== 0) { + throw new Error('oha is required: https://github.com/hatoo/oha') } - throw new Error('Server failed to start') + return (result.stdout || result.stderr).trim() || 'unknown' } -async function retryFetch(url: string, options?: RequestInit, retries = 0): Promise { - try { - return await fetch(url, options) - } catch (e) { - if (retries > 7) throw e - await setTimeout(200) - return retryFetch(url, options, retries + 1) +async function waitForServer(): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + try { + const response = await fetch(`http://127.0.0.1:${PORT}/`) + if (response.ok) return + } catch {} + await setTimeout(100) } + throw new Error('server did not become ready') } -async function stopServer(server: ReturnType): Promise { - if (server.exitCode !== null || server.signalCode !== null) { - return - } - const exited = once(server, 'exit') - server.kill('SIGKILL') +async function stopServer(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return + const exited = once(child, 'exit') + child.kill('SIGKILL') await exited } -async function testEndpoints(): Promise { - // Test GET / - const res1 = await retryFetch('http://127.0.0.1:3000/') - const text1 = await res1.text() - if (res1.status !== 200 || text1 !== 'Hi') { - throw new Error(`Index: Result not match - expected "Hi", got "${text1}"`) +function peakRssMb(pid: number | undefined): number | null { + if (!pid) return null + try { + const match = readFileSync(`/proc/${pid}/status`, 'utf8').match(/VmHWM:\s+(\d+)\s+kB/) + return match ? Number((Number(match[1]) / 1024).toFixed(1)) : null + } catch { + return null } +} - // Test GET /id/:id - const res2 = await retryFetch('http://127.0.0.1:3000/id/1?name=bun') - const text2 = await res2.text() - if (res2.status !== 200 || text2 !== '1 bun') { - throw new Error(`Query: Result not match - expected "1 bun", got "${text2}"`) - } - if (!res2.headers.get('x-powered-by')?.includes('benchmark')) { - throw new Error('Query: X-Powered-By not match') - } +function ohaArgs(scenario: Scenario, duration: string): string[] { + const args = [ + `http://127.0.0.1:${PORT}${scenario.path}`, + '--no-tui', + '--output-format', + 'json', + '-c', + String(CONNECTIONS), + '-z', + duration, + '-m', + scenario.method ?? 'GET', + ] + for (const header of scenario.headers ?? []) args.push('-H', header) + if (scenario.body !== undefined) args.push('-d', scenario.body) + return args +} - // Test POST /json - const body = { hello: 'world' } - const res3 = await retryFetch('http://127.0.0.1:3000/json', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), +function runLoad(scenario: Scenario, duration: string): Sample { + const result = spawnSync('oha', ohaArgs(scenario, duration), { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, }) - const json3 = await res3.json() - if (res3.status !== 200 || JSON.stringify(json3) !== JSON.stringify(body)) { - throw new Error( - `Body: Result not match - expected ${JSON.stringify(body)}, got ${JSON.stringify(json3)}` - ) + if (result.error || result.status !== 0) { + throw new Error(result.stderr || result.error?.message || `oha exited ${result.status}`) } - - // Test an isolated incoming request-header read. - const res4 = await retryFetch('http://127.0.0.1:3000/headers', { - headers: { - 'x-test': '123', - }, - }) - const text4 = await res4.text() - if (res4.status !== 200 || text4 !== '123') { - throw new Error(`Headers: Result not match - expected "123", got "${text4}"`) + const output = JSON.parse(result.stdout) + const expected = String(scenario.expectedStatus ?? 200) + const statuses = Object.keys(output.statusCodeDistribution) + // A duration-limited oha run cancels requests still in flight at its deadline. + const errors = Object.keys(output.errorDistribution ?? {}).filter( + (error) => error !== 'aborted due to deadline' + ) + if (errors.length || statuses.length !== 1 || statuses[0] !== expected) { + throw new Error(`load errors or unexpected responses: ${JSON.stringify({ errors, statuses })}`) + } + return { + rps: output.rps.mean, + latencyMs: output.summary.average * 1000, + throughputMb: output.summary.totalData / output.summary.total / 1024 / 1024, } } -async function runBenchmarkForServer( - serverFile: string, - serverName: string -): Promise { - console.log(`\n${'='.repeat(60)}`) - console.log(`Starting ${serverName}...`) - console.log('='.repeat(60)) - - const server = spawn('node', [serverFile], { - stdio: 'inherit', - cwd: process.cwd(), +async function verifyScenario(scenario: Scenario): Promise { + const headers = Object.fromEntries( + (scenario.headers ?? []).map((header) => { + const separator = header.indexOf(':') + return [header.slice(0, separator), header.slice(separator + 1).trim()] + }) + ) + const response = await fetch(`http://127.0.0.1:${PORT}${scenario.path}`, { + method: scenario.method, + headers, + body: scenario.body, }) - - try { - await waitForServer() - await setTimeout(WARMUP_TIME) - - await testEndpoints() - - console.log('Running benchmarks...\n') - - const benchmarks = [ - { name: 'GET /', url: 'http://127.0.0.1:3000/' }, - { name: 'GET /id/:id', url: 'http://127.0.0.1:3000/id/1?name=bun' }, - { name: 'POST /json', url: 'http://127.0.0.1:3000/json', method: 'POST' }, - { name: 'GET /headers', url: 'http://127.0.0.1:3000/headers' }, - ] - - const results: BenchmarkResult[] = [] - - for (const bench of benchmarks) { - const args = ['--fasthttp', '-c', '500', '-d', '10s'] - if (bench.method === 'POST') { - args.push('-m', 'POST', '-H', 'Content-Type:application/json', '-f', './scripts/body.json') - } - if (bench.name === 'GET /headers') { - args.push('-H', 'x-test:123') - } - args.push(bench.url) - - const output = await new Promise((resolve, reject) => { - let stdout = '' - const bombardier = spawn('bombardier', args) - - bombardier.stdout?.on('data', (data) => { - const text = data.toString() - process.stdout.write(text) - stdout += text - }) - - bombardier.stderr?.on('data', (data) => { - process.stderr.write(data) - }) - - bombardier.on('close', (code) => { - if (code === 0) resolve(stdout) - else reject(new Error(`bombardier exited with code ${code}`)) - }) - }) - - // Parse output - const reqsMatch = output.match(/Reqs\/sec\s+([\d.]+)/) - - results.push({ - name: bench.name, - reqsPerSec: reqsMatch ? parseFloat(reqsMatch[1]) : 0, - }) - } - - console.log('\n✓ All benchmarks completed') - - const ping = results[0]?.reqsPerSec || 0 - const query = results[1]?.reqsPerSec || 0 - const body = results[2]?.reqsPerSec || 0 - const headers = results[3]?.reqsPerSec || 0 - const average = (ping + query + body + headers) / 4 - - return { - server: serverName, - runtime: 'node', - average, - ping, - query, - body, - headers, - } - } catch (error) { - console.error('Error:', (error as Error).message) - throw error - } finally { - console.log('Stopping server...') - await stopServer(server) - } + assert.equal(response.status, scenario.expectedStatus ?? 200) + await scenario.verify(response) } -async function testServer(serverFile: string, serverName: string): Promise { - console.log(`Testing ${serverName}...`) +function median(samples: Sample[]): Sample { + const middle = Math.floor(samples.length / 2) + const value = (key: keyof Sample) => [...samples].sort((a, b) => a[key] - b[key])[middle][key] + return { rps: value('rps'), latencyMs: value('latencyMs'), throughputMb: value('throughputMb') } +} - const server = spawn('node', [serverFile], { - stdio: 'inherit', - cwd: process.cwd(), - }) +function format(value: number): string { + return Math.round(value).toLocaleString('en-US') +} - try { - await waitForServer() - await testEndpoints() - console.log(`✅ ${serverName}`) - return true - } catch (error) { - console.log(`❌ ${serverName}`) - console.log(' ', (error as Error)?.message || error) - return false - } finally { - await stopServer(server) - } +function delta(value: number, baseline: number): string { + const percent = (value / baseline - 1) * 100 + return `${percent >= 0 ? '+' : ''}${percent.toFixed(1)}%` } async function main(): Promise { - const servers = [ - { file: 'src/server-npm.js', name: '@hono/node-server (2.1.0)' }, - { file: 'src/server-srvx.js', name: 'srvx (0.12.5, fast)' }, - { file: 'src/server-dev.js', name: '@hono/node-server (dev)' }, - ] - - console.log('\n' + '='.repeat(60)) - console.log('TEST PHASE') - console.log('='.repeat(60) + '\n') - - const validServers = [] - for (const server of servers) { - const isValid = await testServer(server.file, server.name) - if (isValid) { - validServers.push(server) - } - } - - if (validServers.length === 0) { - console.error('\n❌ No servers passed the tests') - process.exit(1) - } - - console.log(`\n✓ ${validServers.length} server(s) passed the tests`) - console.log('\n' + '='.repeat(60)) - console.log('BENCHMARK PHASE') - console.log('='.repeat(60)) - - const allResults: ServerResult[] = [] - - try { - for (const server of validServers) { - const result = await runBenchmarkForServer(server.file, server.name) - allResults.push(result) - } - - // Print comparison table - console.log('\n' + '='.repeat(60)) - console.log('BENCHMARK RESULTS') - console.log('='.repeat(60) + '\n') - - const formatNumber = (num: number): string => { - return num.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) - } - - const formatDiff = (baseline: number, dev: number): string => { - const diff = ((dev - baseline) / baseline) * 100 - return `${diff > 0 ? '+' : ''}${diff.toFixed(2)}%` - } - - if (allResults.length === 3) { - const npmResult = allResults.find((r) => r.server === '@hono/node-server (2.1.0)') - const srvxResult = allResults.find((r) => r.server === 'srvx (0.12.5, fast)') - const devResult = allResults.find((r) => r.server.includes('dev')) - - if (npmResult && srvxResult && devResult) { - console.log( - '| Benchmark | @hono/node-server (2.1.0) | srvx (0.12.5, fast) | @hono/node-server (dev) | dev vs npm | dev vs srvx |' - ) - console.log( - '| ----------------- | ------------------------- | ------------------- | ----------------------- | ---------- | ----------- |' - ) - console.log( - `| Average | ${formatNumber(npmResult.average).padEnd(25)} | ${formatNumber(srvxResult.average).padEnd(19)} | ${formatNumber(devResult.average).padEnd(23)} | ${formatDiff(npmResult.average, devResult.average).padEnd(10)} | ${formatDiff(srvxResult.average, devResult.average).padEnd(11)} |` - ) - console.log( - `| Ping (GET /) | ${formatNumber(npmResult.ping).padEnd(25)} | ${formatNumber(srvxResult.ping).padEnd(19)} | ${formatNumber(devResult.ping).padEnd(23)} | ${formatDiff(npmResult.ping, devResult.ping).padEnd(10)} | ${formatDiff(srvxResult.ping, devResult.ping).padEnd(11)} |` - ) - console.log( - `| Query (GET /id) | ${formatNumber(npmResult.query).padEnd(25)} | ${formatNumber(srvxResult.query).padEnd(19)} | ${formatNumber(devResult.query).padEnd(23)} | ${formatDiff(npmResult.query, devResult.query).padEnd(10)} | ${formatDiff(srvxResult.query, devResult.query).padEnd(11)} |` - ) - console.log( - `| Body (POST /json) | ${formatNumber(npmResult.body).padEnd(25)} | ${formatNumber(srvxResult.body).padEnd(19)} | ${formatNumber(devResult.body).padEnd(23)} | ${formatDiff(npmResult.body, devResult.body).padEnd(10)} | ${formatDiff(srvxResult.body, devResult.body).padEnd(11)} |` - ) - console.log( - `| Headers (GET) | ${formatNumber(npmResult.headers).padEnd(25)} | ${formatNumber(srvxResult.headers).padEnd(19)} | ${formatNumber(devResult.headers).padEnd(23)} | ${formatDiff(npmResult.headers, devResult.headers).padEnd(10)} | ${formatDiff(srvxResult.headers, devResult.headers).padEnd(11)} |` - ) - } - } else { - // Fallback: original table format - console.log( - '| Server | Runtime | Average | Ping | Query | Body | Headers |' - ) - console.log( - '| -------------------------- | ------- | ------------ | ------------ | ------------ | ------------ | ------------ |' - ) - - const sortedResults = allResults.sort((a, b) => b.average - a.average) - - for (const result of sortedResults) { + if (!Number.isInteger(TRIES) || TRIES < 1) + throw new Error('BENCH_TRIES must be a positive integer') + const toolVersion = ohaVersion() + const systemInfo = [ + `CPU: ${cpus()[0]?.model ?? 'unknown'}`, + `Node.js: ${process.version}`, + `OS: ${process.platform} ${process.arch}`, + `OHA: ${toolVersion}`, + `Config: ${CONNECTIONS} connections, ${WARMUP_DURATION} warmup, ${TRIES} × ${DURATION}`, + ].join('\n') + console.log(systemInfo) + + const results: ServerResult[] = [] + for (const server of [...servers].sort(() => Math.random() - 0.5)) { + console.log(`\n${server.name}`) + const child = spawn(process.execPath, [server.file], { stdio: ['ignore', 'ignore', 'inherit'] }) + try { + await waitForServer() + for (const scenario of scenarios) await verifyScenario(scenario) + + const scenarioResults = new Map() + for (const scenario of scenarios) { + runLoad(scenario, WARMUP_DURATION) + const samples = Array.from({ length: TRIES }, () => runLoad(scenario, DURATION)) + const result = median(samples) + scenarioResults.set(scenario.name, result) console.log( - `| ${result.server.padEnd(26)} | ${result.runtime.padEnd(7)} | ${formatNumber(result.average).padEnd(12)} | ${formatNumber(result.ping).padEnd(12)} | ${formatNumber(result.query).padEnd(12)} | ${formatNumber(result.body).padEnd(12)} | ${formatNumber(result.headers).padEnd(12)} |` + ` ${scenario.name.padEnd(20)} ${format(result.rps).padStart(10)} req/s ` + + `${result.latencyMs.toFixed(2).padStart(8)} ms ${result.throughputMb.toFixed(1).padStart(8)} MiB/s` ) } + results.push({ server, peakRssMb: peakRssMb(child.pid), scenarios: scenarioResults }) + } finally { + await stopServer(child) } + } - console.log() - } catch (error) { - console.error('Failed to run benchmarks:', (error as Error).message) - process.exit(1) + const baseline = results.find((result) => result.server.name === 'node:http') + assert(baseline) + const ordered = servers.map((server) => results.find((result) => result.server === server)!) + const table = [ + `| Scenario | ${ordered.map((result) => result.server.name).join(' | ')} |`, + `| --- | ${ordered.map(() => '---:').join(' | ')} |`, + ] + for (const scenario of scenarios) { + const base = baseline.scenarios.get(scenario.name)!.rps + const cells = ordered.map((result) => { + const rps = result.scenarios.get(scenario.name)!.rps + return result === baseline ? format(rps) : `${format(rps)} (${delta(rps, base)})` + }) + table.push(`| ${scenario.name} | ${cells.join(' | ')} |`) + } + table.push( + `| peak RSS (MiB) | ${ordered.map((result) => result.peakRssMb?.toFixed(1) ?? 'n/a').join(' | ')} |` + ) + + console.log(`\nRequests per second (median; delta vs node:http)\n\n${table.join('\n')}`) + + if (process.argv.includes('--update')) { + const readmePath = fileURLToPath(new URL('../README.md', import.meta.url)) + const readme = readFileSync(readmePath, 'utf8') + const markers = /()[\s\S]*?()/ + assert(markers.test(readme), 'README is missing the automd:bench markers') + const generated = `\`\`\`text\n${systemInfo}\n\`\`\`\n\n${table.join('\n')}` + writeFileSync(readmePath, readme.replace(markers, `$1\n\n${generated}\n\n$2`)) + console.log(`\nUpdated ${readmePath}`) } } -main() +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/benchmarks/fetch/src/app.js b/benchmarks/fetch/src/app.js index 9badf7f..d16ebcc 100644 --- a/benchmarks/fetch/src/app.js +++ b/benchmarks/fetch/src/app.js @@ -1,47 +1,59 @@ -// Referring to https://github.com/SaltyAom/bun-http-framework-benchmark/blob/main/src/bun/bun-web-standard.ts +const LARGE_SIZE = 64 * 1024 +const largeBody = new Uint8Array(LARGE_SIZE).fill(120) export default { fetch: async (request) => { const url = new URL(request.url) - switch (request.method) { - case 'GET': - switch (url.pathname) { - case '/': - return new Response('Hi') - case '/headers': - return new Response(request.headers.get('x-test')) - } + if (request.method === 'HEAD' && url.pathname === '/empty') { + return new Response(null, { status: 204 }) + } - if (url.pathname.startsWith('/id/')) { - const [id, rest] = url.pathname.slice(4).split('/') - if (!rest) - return new Response(`${id} ${url.searchParams.get('name')}`, { - headers: { - 'x-powered-by': 'benchmark', + if (request.method === 'GET') { + switch (url.pathname) { + case '/': + return new Response('Hi') + case '/query': + return new Response(`${url.searchParams.get('id')} ${url.searchParams.get('name')}`) + case '/headers': + return new Response(request.headers.get('x-test'), { + headers: { + 'cache-control': 'public, max-age=60', + 'x-powered-by': 'benchmark', + }, + }) + case '/json': + return Response.json({ message: 'Hello!', ok: true }) + case '/large': + return new Response(largeBody, { + headers: { 'content-type': 'application/octet-stream' }, + }) + case '/stream': + return new Response( + new ReadableStream({ + start(controller) { + for (let offset = 0; offset < LARGE_SIZE; offset += 8192) { + controller.enqueue(largeBody.subarray(offset, offset + 8192)) + } + controller.close() }, - }) - } - - return new Response('Not Found', { - status: 404, - }) - - case 'POST': - switch (url.pathname) { - case '/json': - return Response.json(await request.json()) + }), + { headers: { 'content-type': 'application/octet-stream' } } + ) + } + } - default: - return new Response('Not Found', { - status: 404, - }) + if (request.method === 'POST') { + switch (url.pathname) { + case '/json': + return Response.json(await request.json()) + case '/upload': { + const body = await request.arrayBuffer() + return new Response(String(body.byteLength)) } - - default: - return new Response('Not Found', { - status: 404, - }) + } } + + return new Response('Not Found', { status: 404 }) }, } diff --git a/benchmarks/fetch/src/server-node.js b/benchmarks/fetch/src/server-node.js new file mode 100644 index 0000000..b27c7f3 --- /dev/null +++ b/benchmarks/fetch/src/server-node.js @@ -0,0 +1,61 @@ +import { createServer } from 'node:http' + +const LARGE_SIZE = 64 * 1024 +const largeBody = new Uint8Array(LARGE_SIZE).fill(120) + +createServer((request, response) => { + const url = new URL(request.url, 'http://localhost') + + if (request.method === 'HEAD' && url.pathname === '/empty') { + response.writeHead(204).end() + return + } + + if (request.method === 'GET') { + switch (url.pathname) { + case '/': + response.end('Hi') + return + case '/query': + response.end(`${url.searchParams.get('id')} ${url.searchParams.get('name')}`) + return + case '/headers': + response.setHeader('cache-control', 'public, max-age=60') + response.setHeader('x-powered-by', 'benchmark') + response.end(request.headers['x-test']) + return + case '/json': + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify({ message: 'Hello!', ok: true })) + return + case '/large': + response.setHeader('content-type', 'application/octet-stream') + response.end(largeBody) + return + case '/stream': + response.setHeader('content-type', 'application/octet-stream') + for (let offset = 0; offset < LARGE_SIZE; offset += 8192) { + response.write(largeBody.subarray(offset, offset + 8192)) + } + response.end() + return + } + } + + if (request.method === 'POST' && (url.pathname === '/json' || url.pathname === '/upload')) { + const chunks = [] + request.on('data', (chunk) => chunks.push(chunk)) + request.on('end', () => { + const body = Buffer.concat(chunks) + if (url.pathname === '/json') { + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify(JSON.parse(body.toString()))) + } else { + response.end(String(body.byteLength)) + } + }) + return + } + + response.writeHead(404).end('Not Found') +}).listen(3000) From a217543dc1a80e8c1da86eade4aebf770a9eab0f Mon Sep 17 00:00:00 2001 From: BlankParticle Date: Sun, 16 Aug 2026 18:51:19 +0530 Subject: [PATCH 2/2] chore(benchmarks): add ranked JSON result --- benchmarks/fetch/README.md | 11 +++++++++++ benchmarks/fetch/scripts/bench.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/benchmarks/fetch/README.md b/benchmarks/fetch/README.md index a1396ee..f94d14e 100644 --- a/benchmarks/fetch/README.md +++ b/benchmarks/fetch/README.md @@ -65,6 +65,17 @@ OHA: oha 1.15.0 Config: 100 connections, 2s warmup, 3 × 5s ``` +### JSON round trip + +| Rank | Server | Requests/sec | vs node:http | +| ---: | ----------------------- | -----------: | -----------: | +| 1 | node:http | 81,818 | — | +| 2 | srvx (fast) | 74,833 | -8.5% | +| 3 | @hono/node-server (dev) | 71,683 | -12.4% | +| 4 | @hono/node-server (npm) | 70,050 | -14.4% | + +### All scenarios + | Scenario | node:http | @hono/node-server (npm) | srvx (fast) | @hono/node-server (dev) | | ----------------- | --------: | ----------------------: | --------------: | ----------------------: | | empty response | 117,877 | 105,569 (-10.4%) | 108,464 (-8.0%) | 105,096 (-10.8%) | diff --git a/benchmarks/fetch/scripts/bench.ts b/benchmarks/fetch/scripts/bench.ts index d3a2e16..5c772b7 100644 --- a/benchmarks/fetch/scripts/bench.ts +++ b/benchmarks/fetch/scripts/bench.ts @@ -279,14 +279,36 @@ async function main(): Promise { `| peak RSS (MiB) | ${ordered.map((result) => result.peakRssMb?.toFixed(1) ?? 'n/a').join(' | ')} |` ) - console.log(`\nRequests per second (median; delta vs node:http)\n\n${table.join('\n')}`) + // Match srvx's headline ranking: one representative JSON round-trip result + // per server, ordered by the median requests per second. + const rankingScenario = 'JSON round trip' + const ranking = [...results].sort( + (a, b) => b.scenarios.get(rankingScenario)!.rps - a.scenarios.get(rankingScenario)!.rps + ) + const rankingBaseline = baseline.scenarios.get(rankingScenario)!.rps + const rankingTable = [ + '| Rank | Server | Requests/sec | vs node:http |', + '| ---: | --- | ---: | ---: |', + ...ranking.map((result, index) => { + const rps = result.scenarios.get(rankingScenario)!.rps + return `| ${index + 1} | ${result.server.name} | ${format(rps)} | ${result === baseline ? '—' : delta(rps, rankingBaseline)} |` + }), + ] + + console.log( + `\nJSON round trip (median requests/sec)\n\n${rankingTable.join('\n')}` + + `\n\nAll scenarios (median requests/sec; delta vs node:http)\n\n${table.join('\n')}` + ) if (process.argv.includes('--update')) { const readmePath = fileURLToPath(new URL('../README.md', import.meta.url)) const readme = readFileSync(readmePath, 'utf8') const markers = /()[\s\S]*?()/ assert(markers.test(readme), 'README is missing the automd:bench markers') - const generated = `\`\`\`text\n${systemInfo}\n\`\`\`\n\n${table.join('\n')}` + const generated = + `\`\`\`text\n${systemInfo}\n\`\`\`\n\n` + + `### JSON round trip\n\n${rankingTable.join('\n')}\n\n` + + `### All scenarios\n\n${table.join('\n')}` writeFileSync(readmePath, readme.replace(markers, `$1\n\n${generated}\n\n$2`)) console.log(`\nUpdated ${readmePath}`) }