Skip to content

perf: cache TLS stores, pool clients, and optimize response bodies - #208

Draft
sqdshguy wants to merge 17 commits into
masterfrom
perf/client-pooling-and-body-streaming
Draft

perf: cache TLS stores, pool clients, and optimize response bodies#208
sqdshguy wants to merge 17 commits into
masterfrom
perf/client-pooling-and-body-streaming

Conversation

@sqdshguy

@sqdshguy sqdshguy commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Repeated sessionId requests rebuilt clients and trust stores, fragmented responses crossed N-API once per frame, and abandoned small bodies discarded reusable connections. This PR caches and pools that work, inlines already-received bodies, coalesces streamed reads with bounded buffering, and uses external Node buffers for bodies/chunks ≥256 KiB. It also fixes session/transport lifetime cleanup and preserves TLS isolation for default fetches.

Measured benefit of each change

Change Net benefit and tradeoff in the measured workloads
Cache TLS trust stores (91cc67c) 24.5× string-sessionId HTTP throughput, 49.4× transport create/close throughput. Benefits repeated client construction; isolated HTTP is unchanged.
Pool string-sessionId clients (540ae2d) Another +90% HTTP / +823% HTTPS throughput through client and connection reuse. Existing explicit Session throughput stays neutral.
Inline already-arrived unknown-length bodies (28459b2, 83f2c92) +16–19% fast chunked/gzip throughput. The final implementation preserves this gain while removing the initial 10 ms wait, which added ~11 ms to delayed-header latency. Late/large bodies still stream.
Preserve session jars and finalize sessions (439bfe2) Fixes silent cookie loss after idle expiry and releases collected native state. Throughput is neutral; this is a correctness/lifetime win.
Positional N-API options and flat headers (60c53cc) ~4% less client CPU per small request, with ~1% lower throughput. An efficiency tradeoff.
Bounded streaming pump and coalescing (6c7bd5f) 4.1× fragmented-stream throughput, but continuous 8 MiB streaming/full-body reads are 4–5% slower. Fragmented-stream RSS can rise. Follow-up bypass/permit experiments did not help and are not included.
External Node buffers (a196e02) +4.4% at 1 MiB / +9.2% at 8 MiB for full-body reads. Equal-rate tests show no meaningful large-body RSS penalty at 1 or 4 GiB/s. Small buffers have a memory tradeoff, addressed by the final cutoff below. Bun keeps copies by default and is effectively neutral.
Finalize collected transports (a9c8118) Releases native clients and pooled connections from unclosed, collected transports. Throughput is neutral; a lifetime win.
Drain small abandoned remainders (8206720) +9.5% cancellation throughput, 93% fewer new connections per bounded sample. Normal reads are unchanged; large remainders still drop the connection.
Prevent isolated TLS resumption (da073f1) Restores isolation between default fetch calls. Isolated HTTPS is 38% slower because resumption is disabled; explicit sessions are unchanged. An intentional correctness cost.
Raise external-buffer cutoff to 256 KiB (a4b4be6) At equal load, 64 KiB peak RSS falls 262 → 198 MiB (−25%), and streamed 8 MiB RSS falls 201 → 186 MiB (−7%). Clean candidate throughput: 64 KiB +0.03%, 8 MiB stream −1.14%, both intervals include zero. Equal-rate CPU/request increases ~1.8% / ~2.8%, respectively. Large full-body external paths are retained.
Remove redundant native-body forwarding stream (2697cbb) Bun 8 MiB streaming +1.96% (95% CI +0.68% to +3.27%). Node +0.63%, interval includes zero. Small requests, full-body reads, and fragmented streams remain within noise. First-use tracking stays on the native stream; cloned/arbitrary sources retain the forwarding wrapper.
Skip zero-fill before copying response bytes (4291cd1) Incrementally over the stream change: Bun 8 MiB streaming +3.82% (CI +3.15% to +4.50%), 8 MiB full-body +1.62% (CI +0.66% to +2.60%); Node streaming +1.97% (CI +1.26% to +2.70%). Node full-body reads already use external buffers and stay neutral. 64 KiB and fragmented cases remain within noise. Every copied byte is initialized before exposure to JS; external-buffer policy is unchanged.
Coalesce cold setup for isolated fetch (b2250f2) For 16 concurrent first calls, client CPU falls ~68–78%, cold HTTP batches finish 10–12% sooner, and verified HTTPS batches 4–7% sooner on Node/Bun. One cold request and warmed throughput remain within noise. Only identical client configuration/trust-root construction is deduplicated; cookies, connections, browser profiles, and TLS resumption isolation are preserved.

The 64 KiB memory cost has a concrete cause: external buffers retain 1.8× payload length in Rust Vec capacity on average, versus 1× for 8 MiB full bodies. Tracked native allocations return to zero after forced GC, while allocator-retained RSS can remain high.

Evidence and limits

Apple M4 Pro / Node 24.19.0, separate loopback HTTP/1.1/HTTPS server, release builds. Historical commits are compared with their immediate parents using six alternating pairs; ambiguous cases use ten longer pairs. Follow-up thresholds/pump variants use six rounds, including concurrency-1 checks. Memory runs hold request counts and rates equal for 20 seconds.

These are workload-specific benefits, not a universal net score. Parent deltas cannot be added or multiplied. Connection-heavy comparisons use capped batches to avoid macOS port exhaustion. Intervals use paired log throughput ratios and Student t critical values. HTTP/2, WAN, and other-platform performance are not established.

Before the final threshold adjustment, the complete 11-commit stack versus 74b92fe measured 50.6× string-sessionId HTTP, +13.7% chunked, +19.8% gzip, 4.1× fragmented streams, and +62.5% cancellation throughput. Pooled small requests were ~1% slower and continuous 8 MiB streams ~3.4% slower. The threshold-only candidate was separately checked against da073f1; the full matrix has not been rerun with that last adjustment.

Bun and native profiling follow-up

Bun 1.4.2 built-in CPU profiles and macOS native stack sampling of the release Rust addon identified a redundant JS forwarding stream and Neon's zero-fill immediately before copying response bytes. Post-change profiles no longer contain the forwarding pull on native streams, and zero-fill disappears from the native full-body hot-stack summary. Remaining costs include buffer copies, N-API calls, and socket/runtime work; sample percentages are not throughput gains.

Each new change was measured separately, with six alternating before/after pairs per runtime/workload, fresh client and separate server processes, 200 ms warm-up, 1.5 s measurement, and concurrency 16. All 192 new measured runs completed without errors. These are local HTTP/1.1 results; combined gains are not inferred by adding separate percentages. Profilers and builds did not overlap throughput measurements. Reports and raw samples are kept outside the PR.

Isolated-fetch setup and identity checks

Bare fetch retains its independent-connection policy. Trust-root insertion and ephemeral client construction now coalesce concurrent cache misses, avoiding duplicate cold builds. Eight alternating pairs per runtime/protocol/burst shape measured cold batches; six paired warmed controls per runtime/protocol found no statistically clear sustained throughput regression. The HTTPS fixture uses a configured CA with verification enabled. These 240 new client runs completed without errors; this change does not claim the ~10× HTTPS gain associated with connection reuse.

A new HTTP/2 test verifies two 16-request bursts use 32 distinct connections, never resume TLS, and do not carry cookies or authorization headers between calls. A Rust concurrency test checks matching configurations share one build and different configurations remain separate. Docs clarify that a shared Transport permits connection-level linkage and that isolation does not conceal caller IPs or caller-supplied identifiers.

Validation

  • npm run build
  • npm test: 223 tests passed locally on Node 20 and 24; the prior 222-test head also passed with external buffers disabled on Node 24
  • Streaming checks verify the full byte sequence for paced, buffered, and 4 MiB bodies without assuming one read per server write.
  • Bun integration checks cover native streaming, clone-after-body-access, and every byte of 64 KiB / 1 MiB / 8 MiB full-body responses.
  • All 19 Rust unit tests passed; Bun client passed the 32-connection HTTP/2 isolation check against a separate Node server.
  • npm run typecheck
  • npm run check
  • CI passed on the final head: Linux Node 20/24/26, macOS Node 24, both native builds, TypeScript build, and lint.
  • Initial benchmark: 808 recorded client runs, zero errors.
  • Follow-up: 252 throughput runs and 34 equal-rate runs (2,176,000 measured requests), zero errors.

Draft for review; package versions are unchanged.

build_cert_store() parsed ~150 Mozilla roots plus the system store into a
fresh BoringSSL X509_STORE on every call. Explicit transports and the
ephemeral client cache only paid that once, but requests that pass a
string sessionId with cookieMode 'session' build a client per request,
so every one of them rebuilt the store (~1 ms of X509_NAME_cmp per
request, ~860 req/s on loopback).

CertStore is an Arc, so cache one per TrustStoreMode and clone it.
Loopback sessionId GETs go from ~860 to ~14,900 req/s on Bun and Node.
Requests that name a session by id without an explicit transport fell
through to the 'fresh client' branch of make_request, which built and
dropped a full wreq client per request: a new TCP connection every
time, no keep-alive, and (before the previous commit) a trust store
rebuild as well. On loopback that path ran at ~860 req/s while
createSession() sessions did ~100k.

Cache those clients in TransportManager keyed by (session id, config)
with the same 300s idle timeout as the session cache, so sessions still
never share connections or TLS state, and per-request overrides such as
a different browser still get their own client. drop_session() also
evicts a session's implicit clients.

Loopback sessionId GETs now match sessions: ~111k req/s on Bun and
~99k on Node, with 35 sockets in TIME_WAIT after 660k requests instead
of one per request. Adds a spec that counts server connections.
Bodies were only inlined when the response carried a Content-Length of
at most 2 MiB. Chunked responses never qualify, and neither does any
compressed response, because tower-http's decoder drops the length hint
when it strips Content-Encoding. On real sites that is nearly every
response: a probe of six hosts saw 10 of 12 responses (including a
559-byte example.com page) take the body-handle path, which costs a
moka insert, a mutex, a tokio spawn, and a second FFI round trip via
readBodyAll().

For unknown-length bodies, collect frames that arrive within a 10ms
window. If the body completes under the inline limit it is inlined
exactly like a known-length body, with contentLength set to the decoded
size. Otherwise the collected prefix is replayed ahead of the live
stream through the usual handle, so streaming responses keep working
with at most 10ms of extra first-byte latency. text/event-stream is
exempt and always streams immediately.

Same real-site probe now inlines 9 of 12; the rest are large bodies
that legitimately span more than 10ms. Loopback 4 KB chunked/gzip GETs:
Bun 81k -> 96k req/s, Node 65k -> 93k req/s, within 8% of a
content-length body. Adds /stream/slow and /sse test routes and specs
for inlining, stalled streams, and event streams.
Replace the 10ms wait from 28459b2 with the approach Bun's fetch uses:
a body is inlined only if it has fully arrived by the time the response
is handed to JS. Frames hyper has already delivered are drained without
waiting on the network; if that completes the body under the inline
limit it is inlined, otherwise the prefix is replayed ahead of the live
stream through a body handle. Nothing sits behind a timer any more, so
the text/event-stream exemption and the tokio 'time' feature go away.

One scheduler yield is taken before deciding: hyper resolves the
response as soon as headers are parsed while its connection task is
still delivering frames it already holds, and the chunked decoder only
reports end-of-body on a later poll. Without the yield, 4 KB chunked
GETs on loopback stayed at the pre-fix 83k/63k req/s (Bun/Node); with
it they reach 105k/93k, the same as a Content-Length body. gzip 4 KB is
97k/93k either way.

Real-site probe: 5-6 of 12 responses inline (everything up to ~35 KB);
the 10ms window also caught a 120 KB and a 1.3 MB page, which now
stream as before. Adds a /chunked fixture whose data and terminating
chunk leave in one flush; write()+end() would split them and miss.
Session cookie jars sat in a moka cache with a 300s time-to-idle, and a
lookup miss silently created an empty jar. A createSession() that logged
in, went quiet for five minutes, and fetched again sent no cookies and
raised no error. Nothing documented the expiry.

Native session state now lives in a plain map until dropSession(), the
way tls-client keeps its cffi session table until destroySession(). For
Session objects that are never closed, a FinalizationRegistry drops the
jar and the owned transport on garbage collection, the pattern undici
uses for collected Response bodies and persona-http uses via Drop on
its napi classes. close() marks the state released and unregisters it
so the two never race. Sessions addressed only by a string sessionId
have no JS object to finalize and persist until dropped, as before.

Adds a Rust test that a jar survives lookups until dropped and a JS
lifetime spec, including one that forces GC (the test harness now runs
node with --expose-gc) and observes the jar disappear through the
string-id path. Documents the lifetime under session.close().
The main thread is the throughput ceiling for small requests, and the
profile put most of it in napi calls on either side of the request:

- request() probed an options object with napi_get_property for 24
  fields, most of them absent on the pooled-session hot path.
- The settle callback built headers as an array of [name, value]
  arrays (an allocation plus three element stores per header), stored
  an empty cookies array, and set explicit nulls for bodyHandle,
  bodyBytes, contentLength and diagnostics.

request() now takes positional arguments fetched with a single
napi_get_cb_info: url, method, sessionId, requestId, a flags bitfield
(ephemeral/insecure/compress/redirect/... the way Node's fs binding
takes open flags), timeout, a flat headers array, body, transportId,
and an optional extras object for browser/os/emulation/proxy/trustStore
/onRequestEvent that is only present, and only probed, when one of
those is set. Responses carry headers and cookies as a flat
[name, value, ...] array, the shape of IncomingMessage.rawHeaders,
converted to Headers only on access; cookies and absent fields are
omitted rather than set to null.

Back-to-back A/B on loopback, 16 concurrent small GETs on a session:
Node 96.8k -> 116.8k req/s with main-thread CPU per request down ~24%;
Bun 99.5k -> 103.4k, its main thread was only 65% busy either way.
…eads

Streaming a body cost one FFI round trip per hyper frame: each
readBodyChunk() spawned onto the runtime, awaited a single frame,
settled a promise and allocated a Buffer. hyper's body channel is
mpsc::channel(0) and the connection only reads more once the body is
polled, so nothing ever accumulated between reads. An 8 MB body in
16 KB chunked frames streamed at ~470 req/s against ~1,400 for the
same bytes with a Content-Length, whose frames are ~400 KB.

Each stored body now gets a pump task that pulls frames off the
connection into a buffer bounded at 256 KiB by a semaphore, the way
Node's Readable reads ahead to its highWaterMark and Bun's fetch fills
its native response buffer between JS reads. readBodyChunk() waits for
one frame, then merges whatever is already buffered up to 256 KiB
before settling. An error taken off the buffer mid-merge is held and
returned by the next read. Dropping the handle closes the buffer,
which stops the pump and releases the connection as before.

Loopback, 16 concurrent 8 MB chunked downloads streamed with for-await:
Bun 513 -> 1,242 req/s, Node 457 -> 1,084, within 10-27% of the
Content-Length path (1,365 / 1,495). Adds a /chunked/many test route
and a spec that streams 512 frames and checks bytes, order and that
reads were coalesced.
Every body reached JS through napi_create_buffer_copy: a fresh V8
buffer, zero-filled on Node, then a memcpy. In the 8 MB profile those
two were the largest settle-callback costs, and the copy is why a
~128 MB working set became a 750 MB footprint in the memory soak.

Bodies and stream chunks of 64 KiB or more are now wrapped with
napi_create_external_buffer, which lets the Buffer own the Rust
allocation and free it from a finalizer; below that a copy is cheaper
than the finalizer bookkeeping. The symbol is resolved from the host
with libloading and probed once at module load, so a runtime built
with V8's sandbox that answers napi_no_external_buffers_allowed falls
back to copies (neon's own JsBuffer::external would abort there);
napi-rs does the same. Bytes -> Vec is zero-copy when the Bytes is
uniquely owned, which the inline and coalesced paths guarantee.

Node, 16 concurrent, no forced GC: 64 KB 73.5k -> 86.0k req/s, 1 MB
14.9k -> 15.7k, 8 MB streamed 1,622 -> 1,894, peak footprint after a
1 MB + 8 MB soak 926 MB -> 523 MB.

Bun keeps copies by default. JSC cannot see external bytes, so under
load it collected them late and the same soak peaked at 343 MB against
109 MB with copies, for +2-44% throughput; reporting the sizes through
napi_adjust_external_memory pushed its collection threshold out further
and peaked in the gigabytes. WREQ_EXTERNAL_BUFFERS=1|0 overrides the
per-runtime default. Adds large-body specs covering the threshold.
A Transport from createTransport() that was never closed kept its
native client, and every pooled connection in it, for the life of the
process. Register each Transport in a FinalizationRegistry that calls
dropTransport() on collection, mirroring the Session finalizer; close()
marks the state released and unregisters first so the two never race.

Adds a lifetime spec that watches the server side: a transport's
keep-alive connection closes when close() is called, and closes on its
own once an unreferenced Transport is collected. Documents the lifetime
under transport.close().
…used

Cancelling or dropping a response body with data still unread left the
HTTP/1.1 connection mid-message, so hyper closed it and the next request
opened a fresh connection. A scraper that reads status and headers of
many small responses and moves on churned one connection per request,
which on this host exhausted the ephemeral port range.

The body pump now, when nobody will read the body any more, keeps
reading and discards up to its 256 KiB read-ahead before letting the
stream drop, so the connection returns to the pool; past that limit, or
when a known Content-Length leaves more than that outstanding, it drops
the body and lets hyper close the connection rather than pull megabytes
just to throw them away. undici's dump() draws the same line at 128 KiB.
Abandonment is now signalled with an explicit CancellationToken on the
stored body instead of the mpsc sender closing, since the cache releases
evicted entries from its housekeeper rather than inline.

Loopback, small chunked body cancelled after the first frame: 8.4k ->
32.4k req/s, connections in TIME_WAIT after the run 16,352 -> 583. Large
bodies are unchanged (dropped, connection closed). Adds a spec covering
both the drained-and-reused and dropped-and-closed sides.
Isolated fetch() calls share one cached ephemeral client for speed. That
client's TLS session cache carried session tickets from one call to the
next, so the second connection resumed the first and the server could
see isServerReused() == true, linking two requests that are meant to be
independent. That is a fingerprint/linkability leak for a library whose
purpose is to look like separate browser visits.

Give the ephemeral client a session cache that stores nothing and
returns nothing, so those connections never resume. It leaves
pre_shared_key and therefore the ClientHello untouched: a browser
opening its first connection has no session to resume either, so the
wire fingerprint is unchanged. Sessions and explicit transports keep
wreq's real LRU cache, where resumption within one context is wanted.

Promotes the previously-untracked tls-resumption spec: isolated fetches
now show no resumption across six requests, explicit transports still
resume. Full suite is green.
@sqdshguy
sqdshguy force-pushed the perf/client-pooling-and-body-streaming branch from 3ad94e6 to 54648d7 Compare September 5, 2026 18:05
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.

1 participant