From e818a79278f7b87958b01487f87a64f582f614fd Mon Sep 17 00:00:00 2001 From: Bryan English Date: Tue, 27 Oct 2026 15:49:30 -0400 Subject: [PATCH] bench: add fetch benchmarks Add benchmark/fetch-untraced.bench.js and benchmark/fetch-traced.bench.js, the fetch() counterparts of the HTTP request benchmarks. Each measures the same per-request overhead shape: sequential requests against a local target server, 200 requests per sample over 30 samples, resolved when the response body completes. fetch() pools connections through undici's global dispatcher, so no agent is built. The traced run goes through the undici instrumentation (two spans per request, CLIENT spans via undici:request:* channels) and exports to the same dummy collector, so the traced/untraced difference is the real per-request tracing cost. The fetch benchmarks share the target server and dummy collector machinery in benchmark/helpers/http.js; the HTTP benchmark registration and its output are unchanged. Verification (node v26.12.1): - fetch request (untraced): ~21.4k ops/s - fetch request (traced): ~18.6k ops/s - http request (untraced): ~31.7k ops/s - http request (traced): ~27.9k ops/s Signed-off-by: Bryan English --- benchmark/fetch-traced.bench.js | 5 ++ benchmark/fetch-untraced.bench.js | 5 ++ benchmark/helpers/http.js | 79 ++++++++++++++++++++++++------- 3 files changed, 73 insertions(+), 16 deletions(-) create mode 100644 benchmark/fetch-traced.bench.js create mode 100644 benchmark/fetch-untraced.bench.js diff --git a/benchmark/fetch-traced.bench.js b/benchmark/fetch-traced.bench.js new file mode 100644 index 0000000..501118f --- /dev/null +++ b/benchmark/fetch-traced.bench.js @@ -0,0 +1,5 @@ +'use strict'; + +const { registerFetchBenchmark } = require('./helpers/http'); + +registerFetchBenchmark('fetch request (traced)', { tracing: true }); diff --git a/benchmark/fetch-untraced.bench.js b/benchmark/fetch-untraced.bench.js new file mode 100644 index 0000000..93e09d7 --- /dev/null +++ b/benchmark/fetch-untraced.bench.js @@ -0,0 +1,5 @@ +'use strict'; + +const { registerFetchBenchmark } = require('./helpers/http'); + +registerFetchBenchmark('fetch request (untraced)'); diff --git a/benchmark/helpers/http.js b/benchmark/helpers/http.js index 9bb2e9a..ec21d5b 100644 --- a/benchmark/helpers/http.js +++ b/benchmark/helpers/http.js @@ -1,8 +1,8 @@ 'use strict'; -// Shared machinery for the HTTP benchmarks. Each .bench.js file runs in -// its own child process and tracing activates once per process, so the -// traced and untraced benchmarks live in separate files that each +// Shared machinery for the HTTP and fetch benchmarks. Each .bench.js file +// runs in its own child process and tracing activates once per process, +// so the traced and untraced benchmarks live in separate files that each // register one benchmark through this helper. const { after, before, bench } = require('node:bench'); @@ -13,24 +13,34 @@ const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const close = (server) => new Promise((resolve) => server.close(resolve)); -// Sets up a local target server, a keep-alive agent for the load, and, -// when tracing, a dummy collector: the measurement then covers span -// creation, serialization, and the export round trip. -async function setup({ tracing = false } = {}) { - let collector = null; - if (tracing) { - collector = http.createServer((req, res) => { - req.resume(); - res.end(); - }); - await listen(collector); - microtel.init({ endpoint: `http://127.0.0.1:${collector.address().port}` }); - } +// Starts, when tracing, a dummy collector: the measurement then covers +// span creation, serialization, and the export round trip. +async function startCollector({ tracing }) { + if (!tracing) return null; + + const collector = http.createServer((req, res) => { + req.resume(); + res.end(); + }); + await listen(collector); + microtel.init({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + return collector; +} +// Starts the local target server the benchmark load is sent to. +async function startTargetServer() { const server = http.createServer((req, res) => { res.end('ok'); }); await listen(server); + return server; +} + +// Sets up a local target server, a keep-alive agent for the load, and, +// when tracing, a dummy collector. +async function setup({ tracing = false } = {}) { + const collector = await startCollector({ tracing }); + const server = await startTargetServer(); const port = server.address().port; const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); @@ -77,6 +87,43 @@ function registerHttpBenchmark(name, { tracing = false } = {}) { }); } +// Registers one fetch() benchmark. fetch() pools connections through +// undici's global dispatcher, so no agent is built; the same batch size +// and sample count keep the numbers comparable with the HTTP benchmark. +// Requests are sequential over the pooled connection, so the +// traced/untraced difference reflects per-request work (two spans per +// request), not scheduling or connection noise. +function registerFetchBenchmark(name, { tracing = false } = {}) { + const kRequestsPerSample = 200; + + let server; + let collector; + let url; + + before(async () => { + collector = await startCollector({ tracing }); + server = await startTargetServer(); + url = `http://127.0.0.1:${server.address().port}/bench`; + }); + + after(async () => { + await close(server); + if (collector != null) await close(collector); + }); + + bench(name, { samples: 30 }, async (b) => { + b.start(); + for (let i = 0; i < kRequestsPerSample; i++) { + // The response body is fully consumed, so one iteration covers the + // complete request. + const res = await fetch(url); + await res.arrayBuffer(); + } + b.end(kRequestsPerSample); + }); +} + module.exports = { + registerFetchBenchmark, registerHttpBenchmark, };