v1.1.5
Performance release from the 2026-08 hot-path/memory headroom audit. No API
or wire changes — every change is internal to the request path, the build,
or memory management, so this is a drop-in for 1.1.x. Headlines: +20%
pipelined throughput (new all-time peak), steady-state RSS at raw-uWS
parity, one behavior fix that brings getMethod() in line with its
published type.
Performance — request hot path
- Receive buffer: one per loop, not 64 KiB per connection. Every
connection owned a 64 KiB read buffer thatstd::string::resizehad
zero-filled — the memset made all 16 pages resident even for connections
carrying 200-byte requests, so resident set scaled O(connections) × 64 KiB.
POSIX now uses a single thread-local receive buffer shared by every
connection on the loop (safe: libuv's alloc→read is synchronous and every
consumer copies out before the next read; the WebSocket in-place unmask
completes inside the same read callback). Windows/IOCP keeps a
per-connection buffer — an overlappedWSARecvholds it across the
callback — but no longer zero-fills it. - Pipelined parsing is no longer quadratic. The parser compacted its
buffer witherase(0, consumed)after every request, memmoving the
entire unparsed backlog once per request — O(batch²) bytes moved across a
pipelined batch (~32× write amplification at depth 64). The consumed prefix
now stays in place as a dead region and compaction runs only when the
buffer is fully drained (free) or the dead prefix outgrows the live
remainder. This is the bulk of the pipelined gain. - reqId registry: zero-allocation flat map. The per-thread
reqId→connection registry was astd::unordered_mappaying a node malloc
on every insert and a free on every erase — one of each per request. It is
now a flat open-addressing map (newsrc/flat_map.h): linear probing over
parallel arrays with backward-shift deletion, no per-operation allocation,
and semantics identical to the old map including the id-wrap collision
guard. Covered by a new differential unit suite (randomized workloads
checked againststd::unordered_map, including adversarial probe-chain
clustering). - V8 boundary: malloc-free header and body encoding. The response header
block was a freshstd::stringper response and every header name, header
value, and string body crossed viaString::Utf8Value— a malloc plus two
scans each. Now: the header block is a leased thread-local buffer
(re-entrancy-safe — array getters can run JS and re-enterrespond(),
nested calls get a local); ASCII one-byte strings (names/values, and bodies
up to 64 KiB) are read withWriteOneByteinto reused buffers; and
getHeadersserves header names from a bounded per-server interned-string
cache (same pattern and lifetime as the existing path cache). Two-byte or
non-ASCII strings — and V8 ≥ 14 builds, where the write API changed shape —
take exactly the oldUtf8Valuepath. - Serialization micro-costs. The
Dateheader is cached as the complete
header line (one append, refreshed per second);Content-Lengthand
chunk-size lines use small stack writers instead ofstd::to_string/
snprintf; the corked fast path reserves once up front;
finalizeHeadersruns one pass over the headers instead of four (error
precedence byte-for-byte preserved — proven with targeted precedence
checks); the request-line kept a dead full copy of the target, now removed,
and path/query splitting reuses buffer capacity instead of move-assigning
it away; header-name lowercasing uses a lookup table; per-connection
parsers reference the server's limits struct instead of copying 160 bytes.
Performance — memory hygiene
- Idle keep-alive retention watermarks lowered 64 KiB → 16 KiB for the
response scratch, cork buffer, parsed body, and WebSocket message buffers,
so one burst of large requests/responses no longer parks ~a quarter MB on
every idle connection until it closes. The parser's input buffer keeps
its 64 KiB watermark — pipelined leftovers live there.
Fixed
getMethod()returned""for every known method. The parser only
populates the method string for unrecognized (OTHER) methods, so the
function matched itsindex.d.tscontract only for those. Known methods
now answer from the canonical table ("GET","POST", …). MoroJS was
unaffected (it resolves known methods from the method index), but direct
consumers ofgetMethod()will now see real values.
Build
- arm64 ISA floor raised to armv8.2-a (Graviton2 / Apple Silicon and
later), matching the x86-64-v2 floor x64 has had since 1.1.x — arm64
previously shipped baseline armv8-a. - Opt-in PGO support in the build driver:
MORO_PGO=generateinstruments
a build and writes profiles toMORO_PGO_DIR(defaultbuild/pgo); after
running a representative workload, merge withllvm-profdataand rebuild
withMORO_PGO=use MORO_PGO_PROFILE=<file.profdata>. POSIX/clang only,
never on by default; release artifacts are unchanged unless the release
pipeline opts in.
Measured
Paired same-session A/B on loopback (wrk -c 100 -d 30, best-of-3 per
profile, Node 24.11, Apple M2 Ultra), npm binary vs this build, raw engine
(no framework):
| npm baseline | 1.1.5 | delta | |
|---|---|---|---|
| Req/s, no pipelining | 108,038 | 110,511 | +2.3% (at the loopback ceiling) |
| Req/s, pipelined ×10 | 703,488 | 845,088 | +20.1% |
| RSS under load | 73 MB | 54 MB | −19 MB — raw-uWS parity (53 MB, same box) |
845k pipelined is a new all-time peak for the engine (prior published peak
663,735). The realistic-profile column saturates the single-box loopback
ceiling and understates the per-request win; the pipelined microbenchmark is
where per-request cost is visible. Single-box loopback numbers as always —
the publication-grade matrix should be re-run per the benchmark repo's
protocol.
Compatibility
- No API surface, option, or wire-format changes. Drop-in for 1.1.x;
MoroJS picks it up via^1.1.0. The only observable behavior change is
getMethod()now doing what its type declaration always said.
Verification
- Full socket-level wire matrix green: HTTP conformance + edge + hardening +
regression, WebSocket, limits, TLS + TLS-hardening, permessage-deflate —
154node --test, 0 failures. (One pre-existingstopListening
test-helper race flakes at the same-or-higher rate on the unpatched tree —
tracked separately, not a 1.1.5 change.) - C++ unit suites: 620 checks (141 HTTP parser + 352 WebSocket + 100
permessage-deflate + 27 new flat-map differential), with the parser and
flat-map suites additionally run under ASan/UBSan. - The full MoroJS framework suite (1055 tests, including 75 engine-gated
integration tests) runs green against this build. - Native build clean on ABI 137 (Node 24, darwin/arm64); the full ABI matrix
builds in CI with npm provenance on release.