Skip to content

Latest commit

 

History

25 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MoroJS Performance Benchmarks

h1spec conformance

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.

Quick Start

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 -- --pipelined

Each 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.

HTTP/1.1 conformance

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.

Pinned-core profile (Linux)

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.

Results

MoroJS 1.8.12 / @morojs/engine 1.1.7 — publication run 2026-09-16

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.

Engine era (MoroJS 1.8.0 / @morojs/engine 1.1.x) — publication run 2026-07-10 (previous)

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).


Methodology

Load profile: no pipelining by default

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.

Pipelining: an opt-in microbenchmark, not a real-world number

--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 generator matters

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 one

Why 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.

Runner design decisions (and the bugs that motivated them)

Each of these came from an observed measurement artifact, not theory:

  1. 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.
  2. 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.
  3. 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.
  4. 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=N overrides.
  5. 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.
  6. The readiness probe sends connection: close so no idle keep-alive socket stays parked on the server during measurement.
  7. 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 detached for reliable process-group teardown of cluster workers.
  8. 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 without lsof the old shell-out was a silent no-op.
  9. 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 build row) 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.
  10. 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.38 requirement surfaced).

Fairness guarantees

  • Every server file forces NODE_ENV=production in-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/node adapter (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.

Memory

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.

CPU per request and bytes per response

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>/stat on Linux and ps on 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).

For publishable numbers

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.


What's in this repo

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.

Runner flags

--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).


Why MoroJS

  • 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:http ceiling, 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.

About

MoroJS performance benchmarks

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages