MoroJS adds near-zero framework overhead — and since 1.8.0 it ships its own native C++ engine (@morojs/engine) as the default transport, which meets-or-beats uWebSockets.js in both the realistic and pipelined profiles on the same hardware. The node-http and uWS paths remain available (server.engine: 'node' | 'uws'); you pay for the transport you choose, not for the framework on top of it.
Every number in this repo is reproducible with one command, and this README documents not just the results but the methodology and the reasoning behind it — including the investigations that shaped the tooling, and the honest limits of what a single-machine benchmark can prove. Benchmarks are only as credible as their process.
npm install
# Run everything (all frameworks + raw baselines), realistic no-pipelining load
npm run bench
# Or a single target (defaults to best-of-3 runs)
npm run bench:uws
npm run bench:single
npm run bench:fastify
# Fast sanity check (10s runs - never publish these)
npm run bench:quick
# Run everything and save a results-<timestamp>.md
npm run bench:save
# Add the pipelining capability microbenchmark (NOT production-representative)
npm run bench:uws -- --pipelinedEach run boots the server, waits for readiness, idles briefly, runs the load generator (a separate native process), samples memory, tears down, cools down, and prints a paste-ready markdown table. No second terminal, ever.
Fast is worthless if the parser cuts corners. conformance/
runs the uWebSockets.js team's own 33-test
h1spec compliance suite against
the published npm packages on every push and weekly in CI — raw
@morojs/engine and the full framework on top of it both score 33/33
(uWS scores 33/33, stock Node 32/33). npm run h1spec reproduces it
locally in seconds; see conformance/README.md
for the setup and why it's structured to leave no room for doubt.
For the strictest single-thread comparison — server hard-pinned to one
dedicated core it fully saturates, load generator pinned to different
cores npm run bench:pinned runs raw @morojs/engine vs raw
uWebSockets.js under taskset on Linux (via Docker from any host), with
both packages installed fresh from their registries and affinity +
saturation receipts printed inline. Across independent sessions on
2026-08-05: plain throughput is parity within noise (the winner flips
between sessions), while pipelined ×10 is an engine win in every run,
+13–19%; details, results, and honest limits in
pinned/README.md.
Verified against the published npm packages at the publication bar:
wrk -c 100 -d 40, best-of-3 per target, both profiles, Node v24.11.0,
Apple M2 Ultra. Full table:
results-2026-09-16T01-15-31.md · analysis:
VERIFIED_RESULTS.md (the 2026-09-14 run on 1.8.10 /
1.1.6 is kept there for comparison).
| Server | no pipelining | pipelined ×10 (microbench) | CPU µs/req | RSS |
|---|---|---|---|---|
| MoroJS + @morojs/engine (default, npm) | 111,485 | 913,503 | 8.5 | 66 MB |
| raw @morojs/engine (baseline, no framework) | 108,446 | 832,171 | 9.0 | 54 MB |
| MoroJS + @morojs/engine (clustered, 24 worker threads, npm) | 106,625 | 953,169 | 9.3 | 432 MB |
| raw uWebSockets.js (baseline, no framework) | 106,287 | 652,934 | 9.2 | 47 MB |
| raw Bun.serve (baseline, no framework) | 104,026 | 22,558 | 9.5 | 37 MB |
| MoroJS + uWebSockets.js (npm) | 103,264 | 517,718 | 9.5 | 73 MB |
| MoroJS (single thread, node engine, npm) | 70,396 | 118,135 | 13.6 | 141 MB |
The default path is the fastest row without pipelining — ahead of the raw engine by 2.8%, of bare uWebSockets.js by 4.9% and of raw Bun.serve by 7.2% — and pipelined it is 40% ahead of bare uWebSockets.js, at 8.5 µs of CPU per request and 66 MB. One caveat on the absolutes: this run landed 5–10% below 2026-09-14 on every row, the three baselines included, with wider p99s, so the box was not as quiet; the ordering and the ratios are the finding. The 1.8.12 / 1.1.7 changes (engine callbacks in a Node callback scope, literal route handlers answered inside the engine) do not touch a function-handler hello-world, and the run confirms no regression. The clustered row is 24 worker threads in one process; on one box it lands at the same loopback ceiling as a single thread. Per-generator (wrk, oha, bombardier, autocannon) and Linux-container measurements behind the two releases before this one: candidates/2026-09-13 and candidates/2026-09-11.
Verified 2026-07-10 against the published npm packages at the publication
bar: wrk -c 100 -d 40, best-of-3 per target, both profiles, idle machine,
Node v24.11.0, Apple M2 Ultra. Full table:
results-2026-07-10T08-55-20.md
· analysis: VERIFIED_RESULTS.md.
| Server | no pipelining | pipelined ×10 (microbench) |
|---|---|---|
| raw @morojs/engine (baseline, no framework) | 105,974 | 663,735 |
| MoroJS + @morojs/engine (clustered, npm) | 103,971 | 584,016 |
| MoroJS + @morojs/engine (default, npm) | 102,409 | 572,053 |
| MoroJS + uWebSockets.js (npm) | 101,237 | 520,799 |
| MoroJS (single thread, node engine, npm) | 68,163 | 119,570 |
The clustered row is the same native engine across 24 SO_REUSEPORT workers (2051 MB RSS); on a single box the load generator competes with the workers for cores, so it lands at the same loopback ceiling as one thread — reported for completeness, not as a measure of clustering (see VERIFIED_RESULTS.md).
Beats MoroJS-on-uWS in both profiles — realistic by a hair (the lead trades inside ±2% across sessions) and pipelined every round (+9.8% in the publication run). Bare-vs-bare on an idle machine the raw engine also leads both profiles on average and peak: 106,008 vs 105,635 realistic · 663,735 vs 642,836 pipelined (see VERIFIED_RESULTS.md).
The default and only headline profile is no pipelining — one
request-response per round trip on each connection. This is what real clients
do: browsers disabled HTTP/1.1 pipelining years ago, HTTP/2 replaced the need
for it with true multiplexing, and fetch, HTTP client libraries, load
balancers, and reverse proxies don't pipeline. If you want a benchmark to
predict production behavior, this is the profile.
Its honest limitation on one machine: without pipelining, a single host over loopback usually can't generate enough concurrent load to saturate a fast server, so you end up measuring the client + loopback rather than the server. That's why everything fast converges to ~90–105k req/s on this laptop. The convergence is the truth at this scale — it says "these servers all add negligible overhead over the transport," not "server X can only do 90k." Distinguishing fast servers requires a dedicated load machine and multi-core server hardware, which this suite is designed to support (just point it at real hosts) but which a laptop can't stand in for.
--pipelined adds a second, clearly-labeled column at pipelining depth 10.
This is not production-representative — it exists to remove client
round-trip time and isolate raw server-side request-processing cost. Treat it
as a diagnostic, never a headline.
It does surface a real architectural fact worth knowing:
| Server | no pipelining | pipelined ×10 (microbench) |
|---|---|---|
| MoroJS + @morojs/engine (1.1.x) | 102,409 | 572,053 |
| MoroJS + uWebSockets.js | 101,237 | 520,799 |
| raw Bun.serve | 107,119 | 21,686 |
(wrk, 100 conns, publication run, same machine and session.) uWebSockets.js
batches pipelined responses (cork → process batch → single write) and gains
~5x; @morojs/engine implements the same corking on its Moro-shaped boundary
and edges past uWS; Bun's HTTP
server processes pipelined requests with head-of-line behavior and drops
~5x — a framework-independent runtime trait, verified against raw
Bun.serve with two independent generators, and matching independent results
(HttpArena ranks Bun #1 in mixed workloads and #41 in pipelining). It's a
genuine difference in how the runtimes handle a load shape that production
traffic doesn't actually produce — interesting for internal service-to-service
APIs that do pipeline aggressively, irrelevant for typical web traffic. We
keep it available and labeled so the claim is auditable, not so it can be
quoted as a real-world throughput figure.
The runner auto-selects the strongest installed load generator:
wrk (C) → oha (Rust) → bombardier (Go) → autocannon (Node, always available as the fallback since it ships with this repo)
brew install wrk oha bombardier # get the native generators
node bench.js uws --generator=autocannon # or force oneWhy this order, with receipts. A load generator that is slower than the server measures itself, not the server. autocannon is a Node process; against C++-backed servers it becomes the bottleneck — the same MoroJS + uWS server, same machine, same 10 seconds:
| Generator | Profile | Measured req/sec |
|---|---|---|
| wrk | pipelined ×10 | 508,097 |
| autocannon | pipelined ×10 | 197,274 |
| wrk / oha / bombardier | no pipelining | 87,000–90,000 (agree within 3%) |
autocannon under-reported the pipelined ceiling by 2.5x. The three native generators agreeing within 3% at the no-pipelining profile is the cross-validation that the parsers and method are sound. oha and bombardier don't support pipelining, so they're only eligible for no-pipelining profiles (the runner skips or warns accordingly; wrk pipelines via a generated Lua script). The table footer always states which generator and profile produced the numbers — never compare rows measured with different generators.
Each of these came from an observed measurement artifact, not theory:
- The generator runs as a separate spawned CLI process, never in-process. The first version used autocannon's programmatic API — which runs the load generator inside the runner's own Node process, sharing its event loop, heap, and GC debt from setup work. That systematically under-reported throughput versus running the same tool by hand in a terminal.
- No warmup pass by default. A load warmup before measuring leaves the
server holding warmup GC debt and ~1,000 TIME_WAIT sockets when the
measured run begins; the reference methodology (a careful manual run,
Fastify's official benchmarks) doesn't warm up either. JIT warms in the
first seconds of a 40s run anyway.
--warmup[=secs]opts in, followed by a drain pause. - An idle settle after boot (
--settle, default 3s) replicates the natural pause of the two-terminal manual workflow — boot logging flushes, clustering finishes forking, GC settles. - Best-of-3 by default when targeting 1–2 servers. Identical
back-to-back runs swing ±5% on laptop hardware (measured spread on six
consecutive identical runs: 180k–202k req/s). Best-of-N reports the run
with the least background interference — the standard way to publish, and
the number you'd anchor on after a few manual attempts anyway. Every
individual run is printed so the variance stays visible. Full sweeps
default to 1 run for total-time sanity;
--runs=Noverrides. - One server at a time, cooldown between targets (default 8s) to reduce thermal carryover — laptop thermals can move results ±5% between the first and last target of a long sweep.
- The readiness probe sends
connection: closeso no idle keep-alive socket stays parked on the server during measurement. - Spawn mode was tested, not assumed: detached vs terminal-attached
server processes were A/B'd (3 alternating rounds) — no measurable
difference, so the runner keeps
detachedfor reliable process-group teardown of cluster workers. - Interrupt-safe teardown + port guard. Detached children don't receive
a terminal's Ctrl-C, so the runner traps SIGINT/SIGTERM/exit and kills
every server it spawned. Before each target it also refuses to run if the
port is already occupied — an orphaned or foreign server there would be
silently benchmarked instead of a fresh one (this exact failure produced a
bogus 11k req/s reading during development: the guard exists because of
it). The guard is a plain TCP connect, not
lsof: inside a container image withoutlsofthe old shell-out was a silent no-op. - The gate judges candidate rows against the run's own reference rows.
A baseline captured in the morning and a candidate measured in the
evening differ by more than the candidate: the first gate flagged 15
"regressions" in fixed-rate p99, CPU/req and RSS cells while raw Bun and
raw uWS, whose bits had not changed, showed the same +10–25% moves. Rows
whose bits are identical in both runs (published npm packages,
third-party runtimes: everything that is not a
LOCAL buildrow) are the yardstick: per metric and profile the gate takes their median drift (where the box went) and a robust spread (1.4826 × MAD, what an unchanged row does between these two runs), widens every candidate tolerance by the unfavourable part of the median plus the spread, and never gates a reference row itself. The gate never tightens. Without reference rows in the run there is no widening, and a run with fewer than three of them gets no spread estimate. The drift line is printed above the deltas so the widening is visible. - A server that fails to start says why. The runner keeps the last 30
lines of the server's stderr and prints them with the "skipped" line: a
missing native binary or a glibc floor is a fact, not a shrug (this is
how the uWS row's
GLIBC_2.38requirement surfaced).
- Every server file forces
NODE_ENV=productionin-script, so results can't depend on how the server is launched. - The MoroJS servers print a sanity block at boot showing the resolved per-request feature flags (request logging, tracking, compression, CORS, helmet) — every published run documents exactly what was active. (MoroJS production defaults are benchmark-clean by design: empty middleware chain, lazy request IDs, no per-request logging — the benchmark servers barely need configuration because there's nothing to turn off.)
- Comparison servers are the canonical minimal hello-world for each
framework — same
{"hello":"world"}response shape, same profile, no strawmen. Elysia gets two rows so it's never misrepresented: the official@elysiajs/nodeadapter (apples-to-apples with the Node rows) and native Bun (its home runtime). - Raw-transport baselines (
node:http, uWebSockets.js,Bun.serve) are always included so framework overhead is separable from transport choice. - The table footer stamps generator, profile, run count, Node version, and platform on every table the runner produces.
The RSS column is the server process tree sampled immediately after load — not idle memory. A clustered row sums all worker processes; when MoroJS clusters with worker threads the whole cluster is one process, so that row is a single RSS. Don't compare these values against idle-RSS figures from other sources.
Two columns survive a client-bound harness where req/s converges:
- CPU µs/req — CPU time (user+system, every thread) the server process
tree consumed during the measured run, divided by the requests the generator
counted. Lower is better. Read from
/proc/<pid>/staton Linux andpson macOS;-on Windows. - bytes/resp — bytes the generator read divided by responses: the wire size of a response, so a header change shows up as a number instead of a guess.
Both come from the no-pipelining profile. --rate=N adds a fixed-rate
profile (the generator offers exactly N req/s; the honest comparison is then
the p99 @ N/s column and CPU per request at that load), --keepalive=off
adds a connection-per-request profile (what the-benchmarker measures:
accept + first request), and --perf (Linux, needs perf) records
instructions, cycles and syscalls per request for the run window. Each profile
picks the best generator able to express it; the footer says which.
PROFILING.md covers finding where the CPU goes.
One generator caveat for the connection-per-request profile: wrk sends
Connection: close and relies on the server echoing it (RFC 7230 §6.1 says
a server that will close SHOULD). Servers that close without echoing it
(Bun.serve and uWebSockets.js do not echo; node:http and @morojs/engine do)
look keep-alive to wrk, which then writes a second request into the closed
socket, records one read error per connection and reconnects, so such rows
carry a WARNING: N errors line with N ≈ the request count and a client-side
penalty the other rows do not pay. For a cross-server churn comparison use
--generator=oha (oha --disable-keepalive closes after every response
regardless of the server's headers) or --generator=bombardier (sent with
-H 'Connection: close', which bombardier's own help names as the way to
disable keep-alive for its fasthttp client; its -a flag left 8.4 requests
per connection when checked against a counting server). autocannon cannot
run this profile and the runner skips it with the reason: autocannon always
sends its own Connection: keep-alive and appends the user's close, and
RFC 9110 §7.6.1 makes that list contain close, so a conforming server
closes after one response (node:http, @morojs/engine) while uWebSockets.js
and Bun.serve keep the connection (about 2,800 responses per connection
measured) — the column would compare header handling, not connection cost.
Every generator's connection-per-request mode was verified against a
counting server (requests per accepted connection: wrk 1.00, oha 1.00,
bombardier with the header 1.00).
Close other apps, run on mains power, use a cool machine, keep the 40s
default duration, and prefer individual targets (node bench.js uws) or
npm run bench:save for the full sweep. Always publish the footer line —
generator + profile + platform — alongside any table.
| File | Purpose |
|---|---|
bench.js |
The runner: boot → settle → measure (no-pipelining) → teardown → table |
servers/moro-single-server.js |
MoroJS, standard stack, single thread (port 3110) |
servers/moro-cluster-server.js |
MoroJS, built-in clustering (port 3111) |
servers/moro-uws-server.js |
MoroJS over uWebSockets.js (port 3112) |
servers/raw-node-server.js |
Raw node:http baseline (port 3120) |
servers/raw-uws-server.js |
Raw uWebSockets.js baseline (port 3121) |
servers/raw-engine-server.js |
Raw @morojs/engine baseline (port 3128; raw-engine-local = the sibling engine tree's build, port 3130) |
servers/moro-engine-server.js |
MoroJS with server.engine: 'moro' forced (port 3117) |
PROFILING.md |
How to find where per-request CPU goes (perf / xctrace / --cpu-prof) |
servers/fastify-server.js |
Fastify comparison (port 3122) |
servers/express-server.js |
Express comparison (port 3123) |
servers/koa-server.js |
Koa comparison (port 3124) |
servers/hono-server.js |
Hono via official @hono/node-server (port 3129) |
servers/elysia-node-server.js |
Elysia via official Node adapter (port 3125) |
servers/elysia-bun-server.js |
Elysia native on Bun (port 3126, needs Bun) |
servers/raw-bun-server.js |
Raw Bun.serve baseline (port 3127, needs Bun) |
Bun-runtime targets are skipped with a clear message when Bun isn't
installed. Servers can still be run standalone (npm run server:uws) for
manual two-terminal testing.
--quick 10s runs, short cooldowns (sanity checks only)
--runs=N repeat count per profile (default: 3 for 1-2 targets, else 1)
--duration=40 seconds per measured run
--connections=100 concurrent connections (raise for a real load rig)
--pipelined ALSO run the pipelined x10 microbenchmark (not real-world)
--pipelining=N single run at depth N (N=1 realistic; N>1 microbench)
--rate=N ALSO run a fixed-rate profile at N req/s (latency + CPU at load)
--keepalive=off ALSO run a connection-per-request profile (accept cost)
--perf Linux: perf stat instructions/cycles/syscalls per request
--generator=X wrk | oha | bombardier | autocannon (forced for every profile;
a profile the forced tool cannot express is skipped loudly)
--settle=3 idle seconds between server boot and first measurement
--warmup[=5] opt-in warmup pass before measuring
--cooldown=8 seconds between targets
--save write results-<timestamp>.md AND results-<timestamp>.json
--baseline=<json> compare every row/profile against a saved .json (deltas)
--gate with --baseline: exit 1 on a regression beyond tolerance
(req/s: the baseline's own run spread, min 2%; p99: 5%;
CPU µs/req: 3%; RSS: 10%; each widened by the reference
rows' drift, see design decision 9 below)
--replay=<json> no benchmarking: reload a saved run and re-run the
comparison/gate against --baseline (gate development,
re-judging an old run with a newer gate)
Default (no flag): the realistic no-pipelining profile only.
Local engine builds: npm run engine:link:local points raw-engine-local
(and, through ../MoroJS/node_modules, the engine-local / cluster-local
rows) at the sibling MoroJS Engine working tree's build/ binaries;
npm run engine:unlink:local restores the npm package. npm run bench:baseline
is the full Phase-0 matrix (every profile, best of 3, saved).
- Fast by default: production defaults have an empty middleware chain, lazy request IDs, no per-request logging — nothing to turn off.
- The uWS escape hatch: when you need more than the
node:httpceiling, one config flag moves the same app onto uWebSockets.js — raw-transport throughput with the full framework attached. - Built-in clustering: multi-core scaling on the standard stack with zero configuration.
- TypeScript-first, Zod-native validation, intelligent middleware — the performance comes with the full framework, not a stripped-down core.
Ready to verify? npm install && npm run bench — every number in this
README regenerates from your own machine, and the methodology above explains
exactly what you're looking at.