From c6b94fe882fcf3554b325ac8891fdf6a78916f4c Mon Sep 17 00:00:00 2001 From: Nigro Simone Date: Fri, 31 Jul 2026 06:18:18 +0200 Subject: [PATCH 1/2] perf: collect request bodies natively with uWS collectBody uWS can accumulate the whole body in C++ and hand it over in a single call, so the body parsers no longer need one JS callback per chunk plus a final Buffer.concat. The size limit is enforced by uWS itself, which yields null once the body exceeds it. Parsers that don't keep the buffer past their own call (json, text, urlencoded) now read straight from the memory uWS owns, removing the last copy on that path; raw and the verify option still get a copy since both outlive the callback. The old streaming path stays for inflated bodies, where the limit applies to the decompressed size and so cannot be delegated to uWS. --- src/middlewares.js | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/middlewares.js b/src/middlewares.js index 36ed48e2..ffe7b81c 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -267,6 +267,32 @@ function createBodyParser(defaultType, beforeReturn) { // if we are fast enough (not async), we can do it // otherwise we need to use a stream since it already started streaming it if(!req.receivedData) { + // uWS can accumulate the whole body natively and hand it over in a single call, + // which skips one JS callback per chunk and the final Buffer.concat. + // not usable when inflating, since options.limit applies to the decompressed + // size while collectBody can only cap the compressed bytes it receives. + if(!inflate && Number.isFinite(options.limit)) { + req._res.collectBody(options.limit, ab => { + // uWS returns null when the body exceeded the limit + if(ab === null) { + return next(new Error('Request entity too large')); + } + // uWS may hand over memory it owns and detaches right after this call, + // so only parsers that don't keep the buffer around can view it directly + const buf = beforeReturn.retainsBuffer || options.verify + ? Buffer.from(ab.slice(0)) + : Buffer.from(ab); + if(options.verify) { + try { + options.verify(req, res, buf); + } catch(e) { + return next(e); + } + } + beforeReturn(req, res, next, options, buf); + }); + return; + } req._res.onData((ab, isLast) => { onData(ab); if(isLast) { @@ -300,10 +326,14 @@ const json = createBodyParser('application/json', function(req, res, next, optio next(); }); -const raw = createBodyParser('application/octet-stream', function(req, res, next, options, buf) { +function rawParser(req, res, next, options, buf) { req.body = buf; next(); -}); +} +// req.body outlives the parser, so this one needs its own copy of the body +rawParser.retainsBuffer = true; + +const raw = createBodyParser('application/octet-stream', rawParser); const text = createBodyParser('text/plain', function(req, res, next, options, buf) { let contentType = req.headers['content-type']; From 2affc231bf66760384ab0fae1bb5b0074a5f858a Mon Sep 17 00:00:00 2001 From: Nigro Simone Date: Fri, 31 Jul 2026 07:16:35 +0200 Subject: [PATCH 2/2] benchmark: add a large JSON body scenario The only scenario that exercises the body parsers, body-urlencoded, posts 57 bytes. uWS hands a body that small over in a single chunk, so the suite never measures the work of collecting a body that arrives in pieces, and changes to that path cannot show up in the results. Post 512kb of JSON instead, which is above the ~512kb chunk size uWS delivers bodies in, so the parser has to put several chunks together. --- benchmark/scenarios/body-json-512kb.js | 28 +++++++++++++++++++++++ benchmark/wrk-scripts/post-json-512kb.lua | 7 ++++++ 2 files changed, 35 insertions(+) create mode 100644 benchmark/scenarios/body-json-512kb.js create mode 100644 benchmark/wrk-scripts/post-json-512kb.lua diff --git a/benchmark/scenarios/body-json-512kb.js b/benchmark/scenarios/body-json-512kb.js new file mode 100644 index 00000000..1b8c6da6 --- /dev/null +++ b/benchmark/scenarios/body-json-512kb.js @@ -0,0 +1,28 @@ +'use strict'; + +// The other body-parser scenario posts 57 bytes, which uWS hands over in a single +// chunk, so it never exercises the accumulate path. This one is large enough to +// arrive in several chunks and makes the cost of collecting a body visible. +const PAD = 512 * 1024; + +module.exports = { + name: 'middlewares/body-json-512kb', + path: '/abc', + wrk: { + script: 'post-json-512kb.lua', + connections: 50 + }, + verify: { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ n: 1, pad: 'x'.repeat(PAD) }) + }, + setup(app, express) { + app.use(express.json({ limit: '10mb' })); + app.post('/abc', (req, res) => { + res.send(`${req.body.pad.length}`); + }); + } +}; diff --git a/benchmark/wrk-scripts/post-json-512kb.lua b/benchmark/wrk-scripts/post-json-512kb.lua new file mode 100644 index 00000000..41686fe4 --- /dev/null +++ b/benchmark/wrk-scripts/post-json-512kb.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", 512 * kb) +wrk.body = '{"n":1,"pad":"' .. pad .. '"}'