Skip to content

perf: collect request bodies natively with uWS collectBody - #357

Open
nigrosimone wants to merge 2 commits into
dimdenGD:mainfrom
nigrosimone:perf-collect-body
Open

perf: collect request bodies natively with uWS collectBody#357
nigrosimone wants to merge 2 commits into
dimdenGD:mainfrom
nigrosimone:perf-collect-body

Conversation

@nigrosimone

@nigrosimone nigrosimone commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

TL;DR: no demonstrated win


uWS can accumulate a whole request body in C++ and hand it over in a single call, which lets the body parsers drop the per-chunk bookkeeping they do today.

What changes

On the fast path (!req.receivedData) the parsers used res.onData and rebuilt the body in JS: one callback per chunk, Buffer.from(buf) per chunk into an array, then Buffer.concat at the end. That is two copies of the body plus one JS/C++ crossing per chunk.

With res.collectBody(limit, cb) uWS accumulates the chunks itself (reserving up front when it knows the remaining length) and calls back once. The size limit is enforced natively, and uWS yields null when the body goes over it.

Parsers that do not keep the buffer past their own call json, text, urlencoded, all of which only read it through toString() - now read straight from the memory uWS owns, so the last copy goes away too. raw assigns the buffer to req.body, and a verify callback may hold on to it as well, so both still get a copy.

What stays on the old path

Inflated bodies. options.limit is checked against the decompressed size, while collectBody can only cap the compressed bytes it receives, so delegating the limit would weaken the protection against decompression bombs. Same for a non-finite limit, which cannot be handed to the native side.

Numbers

I could not get a trustworthy measurement on my machine: interleaved runs of the same comparison swung between 0.99x and 1.36x on a 60kb JSON body, and server-side CPU per request moved by ±30% between rounds, so I would rather not quote a figure. What the change removes is concrete though, one callback per chunk, one copy per chunk, one final concat, and for the three text parsers the remaining copy, so the benchmark job here on Linux should be a better judge than anything I can produce on Windows.

Worth noting for expectations: uWS already delivers bodies with a content-length up to about 512kb in a single chunk, so for small payloads this mainly removes copies rather than callbacks. Bodies above that arrive in ~512kb chunks, which is where the old accumulate-and-concat path did the most work.

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.
@nigrosimone
nigrosimone marked this pull request as draft July 31, 2026 05:03
@nigrosimone

This comment was marked as outdated.

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.
@nigrosimone

nigrosimone commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

The 512kb scenario has now run on both sides. Adding #358 on its own gave a baseline of the same scenario on main, so these two rows are directly comparable:

scenario main + scenario (#358) this PR
middlewares/body-json-512kb 0.99x 1.10x
middlewares/body-urlencoded (57 byte control) 2.00x 2.20x

The large-body row moves from 0.99x to 1.10x, which looks like the change doing what it is supposed to. I do not think it can be claimed yet, though: the 57 byte control moved by the same 10% in the same pair of runs, and that scenario is far too small for this change to matter to it. Two rows moving together like that points at a difference between the runs rather than at the code, which also matches the absolute numbers (Express itself went from 21.26k to 15.50k req/sec on the control row between the two runs).

So the honest read is still "no demonstrated win", now with a scenario that could show one if it were there. What would settle it is a few repeated runs of both branches; I cannot trigger reruns on this repo, and pushing empty commits to force them seemed worse than saying so.

The offer from my earlier comment stands: happy to close this if you would rather not carry the extra branch in the parsers for something the suite cannot demonstrate. #358 and #359 are useful on their own either way.

@nigrosimone
nigrosimone marked this pull request as ready for review July 31, 2026 05:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant