Skip to content

benchmark: set NODE_ENV and stop dropping wrk errors on the floor - #367

Closed
nigrosimone wants to merge 2 commits into
dimdenGD:mainfrom
nigrosimone:benchmark/env-and-error-gating
Closed

benchmark: set NODE_ENV and stop dropping wrk errors on the floor#367
nigrosimone wants to merge 2 commits into
dimdenGD:mainfrom
nigrosimone:benchmark/env-and-error-gating

Conversation

@nigrosimone

@nigrosimone nigrosimone commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Three ways the harness can publish a number that does not mean what the table says it means.

NODE_ENV is never set

benchmark.yml has no env: block, so the benchmark runs with NODE_ENV unset. Two consequences:

  • art-template keys debug off process.env.NODE_ENV !== 'production', and in debug mode it forces cache: false and re-reads and recompiles the view from disk on every request, for both frameworks. engines/art therefore spends most of its time in the template compiler rather than in anything either framework does.
  • The same variable decides express 4's view cache in defaultConfiguration(), which a later app.set('env', 'production') does not revisit. uExpress honours the harness setting, express 4 does not, so express does a new View() and a statSync per request and uExpress does not. That row was asymmetric on top of being dominated by dead work.

Set on the step rather than the job, so npm install still pulls devDependencies.

Worth knowing before reading the next engines/art number: this changes the row substantially, and not necessarily in uExpress's favour.

A missing wrk script does not fail

-s pointing at a file that does not exist does not make wrk exit non-zero. It quietly falls back to GET / on the target URL. That is how streaming/readable-hash-4mb measured a 404 for 34 consecutive published runs after the script was renamed at an implied 112 GB/sec of upload, which is the tell nobody was looking for. The script's existence is now checked before the run.

wrk's error lines were parsed and then discarded

parseErrorLines() collected Socket errors: and Non-2xx or 3xx responses: into wrkErrors, which was then only ever interpolated into a message behind if (requestsPerSec === 0). That gate cannot fire for the case it needed to catch: a run that answers 404 to every request still reports a perfectly healthy Requests/sec. Feeding a real wrk output with Non-2xx or 3xx responses: 1448400 to the existing parser returns requestsPerSec = 72420 and passes.

Non-2xx/3xx responses now fail the run. Socket errors are reported rather than failed, since keep-alive teardown can produce a few legitimately.

Response validation was computed and never shown

validateScenarioResponses() already compares status and body hash between the two servers, logs to stderr and stores the result on the row and buildMarkdown never read it. A scenario where the two returned different bodies still rendered as a clean row with a bold speedup. It now marks the row with a warning and gets a section under the table.

Three ways the harness could publish a number that does not mean what it says.

NODE_ENV is never set for the benchmark step. art-template keys `debug` off
process.env.NODE_ENV, and in debug mode it forces cache:false and re-reads and
recompiles the view from disk on every request, so engines/art spends most of
its time in the template compiler on both frameworks. The same variable also
decides express 4's view cache in defaultConfiguration(), which a later
app.set('env', 'production') does not revisit - so the harness setting only took
effect on uExpress and the row was asymmetric on top of being dominated by dead
work. Set on the step rather than the job so npm install still pulls
devDependencies.

A missing `-s` script does not make wrk fail. It falls back to GET / on the
target URL, which is how readable-hash-4mb published a 404 for 34 consecutive
runs at an implied 112 GB/sec of upload. Check the script exists before running.

wrk's error lines were parsed into `wrkErrors` and then only ever used in a
message that could not be reached: the sole gate was requestsPerSec === 0, and a
run answering 404 to every request still reports a perfectly healthy
Requests/sec. Non-2xx/3xx responses now fail the run.

Response validation had the same problem one level up - it ran, printed to
stderr and was stored on the row, but buildMarkdown never read it, so a scenario
where the two servers returned different bodies still rendered as a clean row.
It now marks the row and gets a section under the table, as do socket errors,
which are reported rather than failed since keep-alive teardown can produce a
few legitimately.
@nigrosimone

nigrosimone commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

One prediction that did not materialise: I expected this might flip the row toward express, since in isolated measurements express was the faster renderer once out of debug mode. It did not the row moved slightly further in uExpress's favour.

39 timeouts on the express arm of routes-1000. Under the old harness that line was parsed into wrkErrors and then never reached any output, the row would have published 4.60k req/sec with nothing to indicate part of the run had timed out.


Benchmark Comparison

Test Express req/sec uExpress req/sec Express throughput uExpress throughput uExpress speedup
engines/art 10.64k 18.89k 4.67 MB/sec 8.30 MB/sec 1.78x
middlewares/body-json-512kb 932.60 964.35 153.00 KB/sec 159.16 KB/sec 1.04x
middlewares/body-urlencoded 9.54k 17.59k 1.48 MB/sec 2.75 MB/sec 1.86x
middlewares/compression-file 6.62k 7.03k 3.02 MB/sec 3.21 MB/sec 1.06x
middlewares/express-static 2.62k 5.15k 639.57 MB/sec 1.23 GB/sec 1.97x
routing/hello-world 14.45k 57.32k 2.40 MB/sec 9.57 MB/sec 3.99x
routing/middlewares-100 18.09k 11.38k 2.83 MB/sec 1.79 MB/sec 0.63x
routing/nested-routers 14.37k 44.94k 2.30 MB/sec 7.24 MB/sec 3.15x
routing/routes-1000 4.60k 33.45k 736.26 KB/sec 5.26 MB/sec 7.32x
streaming/readable-hash-4mb 224.47 255.12 49.98 KB/sec 57.05 KB/sec 1.14x
streaming/writable-no-content-length 530.81 561.79 2.60 GB/sec 2.74 GB/sec 1.05x
streaming/writable-with-content-length 537.02 618.76 2.62 GB/sec 3.03 GB/sec 1.16x

1 run(s) reported socket errors. Throughput measured alongside socket errors reflects the load generator as much as the server.

Socket Errors

  • routing/routes-1000 on express: Socket errors: connect 0, read 0, write 0, timeout 39

Five rows sit at 0.94x-1.15x and read as "the two frameworks are equivalent",
when what they actually say is that the scenario spends its budget somewhere
neither framework is involved. body-json-512kb is JSON.parse and utf8 decode of
half a megabyte; the two streaming rows are per-byte copying of a 5 MiB
response; readable-hash-4mb is OpenSSL sha256 over 4 MiB; compression-file is
zlib, reached through the same middleware on both sides.

Those ratios are capped by arithmetic - roughly 1.01x for the streaming rows and
1.02x for the JSON one - so no amount of work on either framework moves them.
Scenarios can now declare what bounds them, and the table marks those rows and
explains them underneath.

Keeping them and labelling them is the point. Dropping them would make the table
look curated, and they are real workloads even if they do not discriminate.
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.

2 participants