Skip to content

v1.1.5

Choose a tag to compare

@M-Chris M-Chris released this 03 Aug 01:30
· 12 commits to main since this release

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 that std::string::resize had
    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 overlapped WSARecv holds it across the
    callback — but no longer zero-fills it.
  • Pipelined parsing is no longer quadratic. The parser compacted its
    buffer with erase(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 a std::unordered_map paying 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 (new src/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 against std::unordered_map, including adversarial probe-chain
    clustering).
  • V8 boundary: malloc-free header and body encoding. The response header
    block was a fresh std::string per response and every header name, header
    value, and string body crossed via String::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-enter respond(),
    nested calls get a local); ASCII one-byte strings (names/values, and bodies
    up to 64 KiB) are read with WriteOneByte into reused buffers; and
    getHeaders serves 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 old Utf8Value path.
  • Serialization micro-costs. The Date header is cached as the complete
    header line (one append, refreshed per second); Content-Length and
    chunk-size lines use small stack writers instead of std::to_string /
    snprintf; the corked fast path reserves once up front;
    finalizeHeaders runs 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 its index.d.ts contract 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 of getMethod() 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=generate instruments
    a build and writes profiles to MORO_PGO_DIR (default build/pgo); after
    running a representative workload, merge with llvm-profdata and rebuild
    with MORO_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 —
    154 node --test, 0 failures. (One pre-existing stopListening
    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.