From 8d1883bae8e81b66fc79d5d95b2117c6c2803424 Mon Sep 17 00:00:00 2001 From: Nigro Simone Date: Tue, 18 Aug 2026 08:18:32 +0200 Subject: [PATCH 1/2] frameworks/node: add benchmark implementation --- frameworks/node/Dockerfile | 7 ++ frameworks/node/README.md | 37 +++++++++ frameworks/node/meta.json | 19 +++++ frameworks/node/server.js | 149 +++++++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 frameworks/node/Dockerfile create mode 100644 frameworks/node/README.md create mode 100644 frameworks/node/meta.json create mode 100644 frameworks/node/server.js diff --git a/frameworks/node/Dockerfile b/frameworks/node/Dockerfile new file mode 100644 index 000000000..9f21bdd33 --- /dev/null +++ b/frameworks/node/Dockerfile @@ -0,0 +1,7 @@ +# No dependencies, so no build stage: the entry is one file on the node image. +FROM node:26-trixie-slim +WORKDIR /app +COPY server.js . +ENV NODE_ENV=production +EXPOSE 8080 +CMD ["node", "server.js"] diff --git a/frameworks/node/README.md b/frameworks/node/README.md new file mode 100644 index 000000000..65dc79fe4 --- /dev/null +++ b/frameworks/node/README.md @@ -0,0 +1,37 @@ +# node + +Node's own HTTP server, `node:http`, with no framework on top and no dependencies at all. + +## Stack + +- **Language:** JavaScript +- **Runtime:** Node.js 26 +- **Framework:** none, `http.createServer` from the standard library +- **Build:** Single stage on `node:26-trixie-slim` + +## Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/pipeline` | GET | Returns `ok` (plain text) | +| `/baseline11` | GET/POST | Sums query parameter values, plus the body for POST | +| `/baseline2` | GET | Sums query parameter values | +| `/json/:count` | GET | Serializes a slice of the dataset, gzipped when the client accepts it | +| `/upload` | POST | Counts the bytes of the request body | + +## Notes + +- Node was on the board only through frameworks. This entry is the plain HTTP floor of the + runtime itself, so express, fastify, koa, nestjs and the two h3 entries can be read against it. +- `node:cluster` forks one worker per core, but the round robin of the cluster primary is not in + the path: each worker binds 8080 itself with `reusePort`, so the kernel spreads the accepts the + same way bun and deno do. node sets `exclusive` on its own when `reusePort` is true, which is + what takes the cluster listen path out. The other node entries here still use the round robin. +- Routing is a handful of string comparisons on `req.url`, and the query is parsed by hand, since + with no framework there is no router and no parser to measure. +- `node:http` negotiates nothing, so `/json` gzips its own body with `zlib` when `Accept-Encoding` + asks for it, at the default level, and sends it uncompressed otherwise. +- `/upload` counts the body chunk by chunk instead of buffering it, which keeps 20 MB requests on + hundreds of connections out of memory. +- The dataset is read once per worker at startup. A missing file leaves an empty list, since the + profiles other than json run without the mount. diff --git a/frameworks/node/meta.json b/frameworks/node/meta.json new file mode 100644 index 000000000..539cb8cef --- /dev/null +++ b/frameworks/node/meta.json @@ -0,0 +1,19 @@ +{ + "display_name": "node", + "language": "JS", + "type": "engine", + "mode": "standard", + "engine": "node:http", + "description": "Node's own HTTP server, node:http, with no framework and no dependencies, and one worker per core sharing the port through reusePort.", + "repo": "https://github.com/nodejs/node", + "enabled": true, + "tests": [ + "baseline", + "pipelined", + "limited-conn", + "json", + "json-comp", + "upload" + ], + "maintainers": [] +} diff --git a/frameworks/node/server.js b/frameworks/node/server.js new file mode 100644 index 000000000..c9e2017ee --- /dev/null +++ b/frameworks/node/server.js @@ -0,0 +1,149 @@ +// node:http with nothing on top: no framework, no router, no dependencies. This is +// the floor the node framework entries are read against. +const cluster = require('node:cluster'); +const http = require('node:http'); +const os = require('node:os'); +const fs = require('node:fs'); +const zlib = require('node:zlib'); + +// The container is pinned to a cpuset or a cpu quota, so availableParallelism() +// alone would fork one worker per host core. Same helper as the other node entries. +function getCPUCount() { + try { + const max = fs.readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim(); + const [quota, period] = max.split(' '); + if (quota !== 'max') { + const cgroup = Math.floor(Number(quota) / Number(period)); + if (cgroup >= 1) return cgroup; + } + } catch {} + return os.availableParallelism ? os.availableParallelism() : os.cpus().length; +} + +if (cluster.isPrimary) { + const numCPUs = getCPUCount(); + for (let i = 0; i < numCPUs; i++) cluster.fork(); +} else { + // A missing dataset serves an empty list instead of taking the worker down + let datasetItems = []; + try { + datasetItems = JSON.parse(fs.readFileSync(process.env.DATASET_PATH || '/data/dataset.json', 'utf8')); + } catch (e) {} + + const SERVER_HDR = 'node'; + + function sendText(res, body) { + res.writeHead(200, { + 'content-type': 'text/plain', + 'content-length': Buffer.byteLength(body), + 'server': SERVER_HDR + }); + res.end(body); + } + + // No querystring module and no URL object: the profiles send a handful of + // integer parameters, and parsing them by hand is the whole cost here. + function sumQuery(query) { + let sum = 0; + for (const pair of query.split('&')) { + const eq = pair.indexOf('='); + if (eq < 0) continue; + const n = parseInt(pair.slice(eq + 1), 10); + if (n === n) sum += n; + } + return sum; + } + + function queryValue(query, name) { + for (const pair of query.split('&')) { + if (pair.startsWith(name) && pair[name.length] === '=') { + return pair.slice(name.length + 1); + } + } + return ''; + } + + function json(req, res, path, query) { + let count = parseInt(path.slice(6), 10) || 0; + if (count < 0) count = 0; + if (count > datasetItems.length) count = datasetItems.length; + const m = parseInt(queryValue(query, 'm'), 10) || 1; + const items = datasetItems.slice(0, count).map(d => ({ + id: d.id, name: d.name, category: d.category, + price: d.price, quantity: d.quantity, active: d.active, + tags: d.tags, rating: d.rating, + total: d.price * d.quantity * m + })); + const body = JSON.stringify({ items, count }); + + // json-comp: node:http negotiates nothing, so Accept-Encoding is read here, + // per request, with the zlib defaults. Nothing at all when it is not asked for + const accept = req.headers['accept-encoding']; + if (accept !== undefined && accept.includes('gzip')) { + const gz = zlib.gzipSync(body); + res.writeHead(200, { + 'content-type': 'application/json', + 'content-encoding': 'gzip', + 'vary': 'accept-encoding', + 'content-length': gz.length, + 'server': SERVER_HDR + }); + res.end(gz); + return; + } + res.writeHead(200, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + 'server': SERVER_HDR + }); + res.end(body); + } + + const server = http.createServer((req, res) => { + // req.url is the request target, so the path is everything before the "?" + const url = req.url; + const mark = url.indexOf('?'); + const path = mark < 0 ? url : url.slice(0, mark); + const query = mark < 0 ? '' : url.slice(mark + 1); + + if (path === '/pipeline') return sendText(res, 'ok'); + + if (path === '/baseline11') { + const querySum = sumQuery(query); + if (req.method !== 'POST') return sendText(res, String(querySum)); + // Content-Length or chunked, node:http gives the same data events either way + let body = ''; + req.setEncoding('utf8'); + req.on('data', chunk => body += chunk); + req.on('end', () => { + let total = querySum; + const n = parseInt(body.trim(), 10); + if (n === n) total += n; + sendText(res, String(total)); + }); + return; + } + + if (path.startsWith('/json/')) return json(req, res, path, query); + + if (path === '/upload' && req.method === 'POST') { + // Counted chunk by chunk: the profile posts up to 20 MB per request over + // hundreds of connections, and buffering the bodies would only cost memory + let size = 0; + req.on('data', chunk => size += chunk.length); + req.on('end', () => sendText(res, String(size))); + return; + } + + if (path === '/baseline2') return sendText(res, String(sumQuery(query))); + + res.writeHead(404, { 'content-type': 'text/plain', 'content-length': 9, 'server': SERVER_HDR }); + res.end('Not found'); + }); + + // Scaling is cluster to fork the workers, but not its round robin: with reusePort + // every worker binds 8080 itself with SO_REUSEPORT and the kernel spreads the + // accepts, the way bun and deno do it. node sets exclusive on its own when + // reusePort is true, so the cluster listen path is out of the way. + server.listen({ port: 8080, host: '0.0.0.0', reusePort: true }); +} From e06638961648e14e5258c5e057ec27a6aaed38d8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 18 Aug 2026 23:30:50 +0000 Subject: [PATCH 2/2] Benchmark results: node [skip ci] --- site/data/frameworks.json | 8 + site/data/results/node.json | 243 ++++++++++++++++++++ site/static/logs/baseline/4096/node.log | 0 site/static/logs/baseline/512/node.log | 0 site/static/logs/json-comp/16384/node.log | 0 site/static/logs/json-comp/4096/node.log | 0 site/static/logs/json-comp/512/node.log | 0 site/static/logs/json/4096/node.log | 0 site/static/logs/limited-conn/4096/node.log | 0 site/static/logs/limited-conn/512/node.log | 0 site/static/logs/pipelined/4096/node.log | 0 site/static/logs/pipelined/512/node.log | 0 site/static/logs/upload/256/node.log | 0 site/static/logs/upload/32/node.log | 0 14 files changed, 251 insertions(+) create mode 100644 site/data/results/node.json create mode 100644 site/static/logs/baseline/4096/node.log create mode 100644 site/static/logs/baseline/512/node.log create mode 100644 site/static/logs/json-comp/16384/node.log create mode 100644 site/static/logs/json-comp/4096/node.log create mode 100644 site/static/logs/json-comp/512/node.log create mode 100644 site/static/logs/json/4096/node.log create mode 100644 site/static/logs/limited-conn/4096/node.log create mode 100644 site/static/logs/limited-conn/512/node.log create mode 100644 site/static/logs/pipelined/4096/node.log create mode 100644 site/static/logs/pipelined/512/node.log create mode 100644 site/static/logs/upload/256/node.log create mode 100644 site/static/logs/upload/32/node.log diff --git a/site/data/frameworks.json b/site/data/frameworks.json index 47ad9e7c0..10148724e 100644 --- a/site/data/frameworks.json +++ b/site/data/frameworks.json @@ -839,6 +839,14 @@ "engine": "v8", "mode": "tuned" }, + "node": { + "dir": "node", + "description": "Node's own HTTP server, node:http, with no framework and no dependencies, and one worker per core sharing the port through reusePort.", + "repo": "https://github.com/nodejs/node", + "type": "engine", + "engine": "node:http", + "mode": "standard" + }, "pedestal": { "dir": "pedestal", "description": "Pedestal connector-map benchmark entry using the standard Jetty-backed server.", diff --git a/site/data/results/node.json b/site/data/results/node.json new file mode 100644 index 000000000..f4fbb95a9 --- /dev/null +++ b/site/data/results/node.json @@ -0,0 +1,243 @@ +{ + "framework": "node", + "results": { + "baseline-4096": { + "framework": "node", + "language": "JS", + "rps": 892087, + "avg_latency": "4.60ms", + "p99_latency": "6.22ms", + "cpu": "6553.9%", + "memory": "1.6GiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "139.49MB/s", + "input_bw": "68.91MB/s", + "reconnects": 0, + "status_2xx": 4460435, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "baseline-512": { + "framework": "node", + "language": "JS", + "rps": 907802, + "avg_latency": "563us", + "p99_latency": "1.34ms", + "cpu": "6679.8%", + "memory": "1.5GiB", + "connections": 512, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "141.96MB/s", + "input_bw": "70.13MB/s", + "reconnects": 0, + "status_2xx": 4539011, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "json-4096": { + "framework": "node", + "language": "JS", + "rps": 713493, + "avg_latency": "5.40ms", + "p99_latency": "152.50ms", + "cpu": "6393.6%", + "memory": "5.0GiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "2.45GB/s", + "input_bw": "34.02MB/s", + "reconnects": 142242, + "status_2xx": 3567465, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "json-comp-16384": { + "framework": "node", + "language": "JS", + "rps": 285229, + "avg_latency": "50.17ms", + "p99_latency": "1.32s", + "cpu": "6180.5%", + "memory": "9.5GiB", + "connections": 16384, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "396.84MB/s", + "input_bw": "21.22MB/s", + "reconnects": 56306, + "status_2xx": 1426149, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "json-comp-4096": { + "framework": "node", + "language": "JS", + "rps": 289309, + "avg_latency": "13.78ms", + "p99_latency": "325.60ms", + "cpu": "6307.9%", + "memory": "9.0GiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "402.40MB/s", + "input_bw": "21.52MB/s", + "reconnects": 57508, + "status_2xx": 1446545, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "json-comp-512": { + "framework": "node", + "language": "JS", + "rps": 281475, + "avg_latency": "1.81ms", + "p99_latency": "6.96ms", + "cpu": "5817.4%", + "memory": "8.9GiB", + "connections": 512, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "391.56MB/s", + "input_bw": "20.94MB/s", + "reconnects": 56289, + "status_2xx": 1407375, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "limited-conn-4096": { + "framework": "node", + "language": "JS", + "rps": 768440, + "avg_latency": "5.30ms", + "p99_latency": "87.50ms", + "cpu": "6415.9%", + "memory": "5.4GiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "120.16MB/s", + "input_bw": "59.36MB/s", + "reconnects": 385043, + "status_2xx": 3842203, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "limited-conn-512": { + "framework": "node", + "language": "JS", + "rps": 747342, + "avg_latency": "676us", + "p99_latency": "6.80ms", + "cpu": "6187.4%", + "memory": "5.3GiB", + "connections": 512, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "116.86MB/s", + "input_bw": "57.73MB/s", + "reconnects": 373689, + "status_2xx": 3736710, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "pipelined-4096": { + "framework": "node", + "language": "JS", + "rps": 1837282, + "avg_latency": "35.61ms", + "p99_latency": "44.90ms", + "cpu": "6553.1%", + "memory": "2.5GiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 16, + "bandwidth": "287.25MB/s", + "reconnects": 0, + "status_2xx": 9186411, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "pipelined-512": { + "framework": "node", + "language": "JS", + "rps": 1848457, + "avg_latency": "4.43ms", + "p99_latency": "7.68ms", + "cpu": "6669.2%", + "memory": "2.3GiB", + "connections": 512, + "threads": 64, + "duration": "5s", + "pipeline": 16, + "bandwidth": "289.05MB/s", + "reconnects": 0, + "status_2xx": 9242288, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "upload-256": { + "framework": "node", + "language": "JS", + "rps": 2156, + "avg_latency": "115.53ms", + "p99_latency": "656.70ms", + "cpu": "5931.9%", + "memory": "5.6GiB", + "connections": 256, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "356.28KB/s", + "input_bw": "17.10GB/s", + "reconnects": 2115, + "status_2xx": 10783, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "upload-32": { + "framework": "node", + "language": "JS", + "rps": 2058, + "avg_latency": "15.48ms", + "p99_latency": "78.90ms", + "cpu": "2462.3%", + "memory": "5.3GiB", + "connections": 32, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "340.07KB/s", + "input_bw": "16.32GB/s", + "reconnects": 2056, + "status_2xx": 10292, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + } + } +} diff --git a/site/static/logs/baseline/4096/node.log b/site/static/logs/baseline/4096/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/baseline/512/node.log b/site/static/logs/baseline/512/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json-comp/16384/node.log b/site/static/logs/json-comp/16384/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json-comp/4096/node.log b/site/static/logs/json-comp/4096/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json-comp/512/node.log b/site/static/logs/json-comp/512/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json/4096/node.log b/site/static/logs/json/4096/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/limited-conn/4096/node.log b/site/static/logs/limited-conn/4096/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/limited-conn/512/node.log b/site/static/logs/limited-conn/512/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/pipelined/4096/node.log b/site/static/logs/pipelined/4096/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/pipelined/512/node.log b/site/static/logs/pipelined/512/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/upload/256/node.log b/site/static/logs/upload/256/node.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/upload/32/node.log b/site/static/logs/upload/32/node.log new file mode 100644 index 000000000..e69de29bb