From 6c9239c5fc7443244047e616aae51a523a68f091 Mon Sep 17 00:00:00 2001 From: Nigro Simone Date: Sat, 1 Aug 2026 07:39:32 +0200 Subject: [PATCH] benchmark: add scenarios for the request shapes the suite never covered grep -r "res.json\|req.params\|req.query" over benchmark/scenarios returned nothing. The three most common things an Express application does were not measured at all, while five of the twelve rows sat on workloads dominated by zlib, sha256, JSON.parse or loopback bandwidth, where the ratio is capped by arithmetic no matter what either framework does. routing/api-endpoint: GET /api/users/:userId/posts?fields=...&limit=10 through a mounted router, answering with res.json(). Param extraction, query parsing and serialisation - the shape where the framework's own work is a real share of the request. middlewares/realistic-stack: helmet + cors + cookie-parser + json + morgan, which is what a service actually mounts, as opposed to middlewares-100's 100 no-ops. morgan writes to a sink so the row measures the formatting every request pays rather than the runner's terminal, and 'combined' is used because that is the production default. middlewares/body-json-4kb: a body-parser row at a size an API actually receives. The 512 KiB one is kept as the stress case - it measures JSON.parse, this one measures getting the bytes to it. connections/high-concurrency: 1000 connections against a trivial handler. Every other scenario runs at 50-200, where connection handling is free, so nothing exercised the part where uWS differs from node:http structurally. All four verified to return identical status and body on both frameworks. Local preview, 5 paired runs on a machine whose ratios run about a third of CI's: api-endpoint 1.88x, body-json-4kb 1.19x, realistic-stack 1.12x. --- benchmark/scenarios/api-endpoint.js | 31 ++++++++++++++++ benchmark/scenarios/body-json-4kb.js | 29 +++++++++++++++ benchmark/scenarios/high-concurrency.js | 20 +++++++++++ benchmark/scenarios/realistic-stack.js | 43 +++++++++++++++++++++++ benchmark/wrk-scripts/post-json-4kb.lua | 7 ++++ benchmark/wrk-scripts/realistic-stack.lua | 4 +++ 6 files changed, 134 insertions(+) create mode 100644 benchmark/scenarios/api-endpoint.js create mode 100644 benchmark/scenarios/body-json-4kb.js create mode 100644 benchmark/scenarios/high-concurrency.js create mode 100644 benchmark/scenarios/realistic-stack.js create mode 100644 benchmark/wrk-scripts/post-json-4kb.lua create mode 100644 benchmark/wrk-scripts/realistic-stack.lua diff --git a/benchmark/scenarios/api-endpoint.js b/benchmark/scenarios/api-endpoint.js new file mode 100644 index 0000000..53c3aef --- /dev/null +++ b/benchmark/scenarios/api-endpoint.js @@ -0,0 +1,31 @@ +'use strict'; + +// The suite had no scenario shaped like an actual API endpoint: `grep -r "res.json\|req.params\|req.query"` +// over benchmark/scenarios returned nothing. That is the most common request shape in an Express +// application, and the one where the framework's own work is a meaningful share of the request +// rather than a rounding error next to zlib or JSON.parse. +module.exports = { + name: 'routing/api-endpoint', + path: '/api/users/42/posts?fields=id,title,author&limit=10', + setup(app, express) { + const apiRouter = express.Router(); + + apiRouter.get('/users/:userId/posts', (req, res) => { + const fields = String(req.query.fields || '').split(','); + const limit = Number(req.query.limit) || 0; + const items = []; + for (let i = 0; i < limit; i++) { + items.push({ id: i, title: `post ${i}`, author: req.params.userId }); + } + + res.json({ + userId: req.params.userId, + fields, + count: items.length, + items + }); + }); + + app.use('/api', apiRouter); + } +}; diff --git a/benchmark/scenarios/body-json-4kb.js b/benchmark/scenarios/body-json-4kb.js new file mode 100644 index 0000000..3e5aaf4 --- /dev/null +++ b/benchmark/scenarios/body-json-4kb.js @@ -0,0 +1,29 @@ +'use strict'; + +// body-json-512kb is a stress case: at half a megabyte JSON.parse dominates and the framework is +// about 1% of the request, so that row cannot move. A few KB is what an API actually receives, and +// at that size the body plumbing around the parse is visible. Both rows are kept - one measures the +// parser, this one measures getting the bytes to it. +const PAD = 4 * 1024; + +module.exports = { + name: 'middlewares/body-json-4kb', + path: '/abc', + wrk: { + script: 'post-json-4kb.lua', + connections: 200 + }, + verify: { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ n: 1, pad: 'x'.repeat(PAD) }) + }, + setup(app, express) { + app.use(express.json()); + app.post('/abc', (req, res) => { + res.send(`${req.body.pad.length}`); + }); + } +}; diff --git a/benchmark/scenarios/high-concurrency.js b/benchmark/scenarios/high-concurrency.js new file mode 100644 index 0000000..7733930 --- /dev/null +++ b/benchmark/scenarios/high-concurrency.js @@ -0,0 +1,20 @@ +'use strict'; + +// Every other scenario runs at 50-200 connections, where connection handling is free. uWS's +// advantage over node:http at high connection counts is structural - per-connection memory and +// accept handling - rather than anything in the request path, and nothing in the suite exercised it. +// The handler is deliberately trivial so the row measures connections and not work. +// +// Caveat: wrk shares the runner with the server, so at this connection count part of what is +// measured is the generator. Two wrk threads on a 4 vCPU runner leaves the server two. +module.exports = { + name: 'connections/high-concurrency', + path: '/ping', + wrk: { + threads: 2, + connections: 1000 + }, + setup(app) { + app.get('/ping', (req, res) => res.send('pong')); + } +}; diff --git a/benchmark/scenarios/realistic-stack.js b/benchmark/scenarios/realistic-stack.js new file mode 100644 index 0000000..d03c1c9 --- /dev/null +++ b/benchmark/scenarios/realistic-stack.js @@ -0,0 +1,43 @@ +'use strict'; + +const helmet = require('helmet'); +const cors = require('cors'); +const cookieParser = require('cookie-parser'); +const morgan = require('morgan'); + +// middlewares-100 measures 100 no-ops, which no application runs. This is the stack a typical +// Express service actually mounts, at the depth it actually mounts it. +// morgan writes to a sink rather than stdout so the row measures the formatting work every request +// pays, not the runner's terminal. 'combined' is used because that is the production default, and +// it includes the remote address, which both frameworks have to resolve their own way. +module.exports = { + name: 'middlewares/realistic-stack', + path: '/profile', + wrk: { + script: 'realistic-stack.lua', + connections: 200 + }, + // keep these in sync with realistic-stack.lua: the verify request and the load request are + // separate definitions, so cors would otherwise be exercised only by one of them + verify: { + method: 'GET', + headers: { + 'Cookie': 'sid=abc123; theme=dark', + 'Origin': 'https://example.com' + } + }, + setup(app, express) { + app.use(helmet()); + app.use(cors()); + app.use(cookieParser()); + app.use(express.json()); + app.use(morgan('combined', { stream: { write: () => {} } })); + + app.get('/profile', (req, res) => { + res.json({ + sid: req.cookies.sid || null, + theme: req.cookies.theme || null + }); + }); + } +}; diff --git a/benchmark/wrk-scripts/post-json-4kb.lua b/benchmark/wrk-scripts/post-json-4kb.lua new file mode 100644 index 0000000..36dda00 --- /dev/null +++ b/benchmark/wrk-scripts/post-json-4kb.lua @@ -0,0 +1,7 @@ +wrk.method = "POST" +wrk.path = "/abc" +wrk.headers["Content-Type"] = "application/json" + +local kb = 1024 +local pad = string.rep("x", 4 * kb) +wrk.body = '{"n":1,"pad":"' .. pad .. '"}' diff --git a/benchmark/wrk-scripts/realistic-stack.lua b/benchmark/wrk-scripts/realistic-stack.lua new file mode 100644 index 0000000..070835f --- /dev/null +++ b/benchmark/wrk-scripts/realistic-stack.lua @@ -0,0 +1,4 @@ +wrk.method = "GET" +wrk.path = "/profile" +wrk.headers["Cookie"] = "sid=abc123; theme=dark" +wrk.headers["Origin"] = "https://example.com"