perf(ext/fetch): fast-path Response reconstruction#35495
Merged
nathanwhit merged 4 commits intoJun 26, 2026
Conversation
A trivial Hono app (c.json + a header-mutating middleware) left significant throughput on the table on Deno.serve and emitted chunked encoding where a fixed Content-Length was possible. Profiling pinned it to Deno's own Web API handling for a common framework pattern: the middleware sets a header after await next(), so c.header() runs new Response(oldResponse.body, oldResponse). That exposed three avoidable costs: 1. Reading .body turned the fast string body into a ReadableStream, serving the response via the streaming/chunked path (3 sendto syscalls/req, no Content-Length). extractBody now recovers the original static body when a Response/Request is reconstructed from an undisturbed static-backed stream, restoring the single-write fast static path. 2. fillHeaderList sent the already-validated Headers init object through the full webidl sequence<sequence<ByteString>> converter, re-validating every entry. It now copies a Headers instance's entries directly. 3. The .body getter eagerly built and encoded a ReadableStream that recovery immediately discards. It is now materialized lazily (high-water-mark 0, via createReadableStream, which also skips the webidl UnderlyingSource conversion), so the encode only happens on an actual read. Result: 54.9k -> 98.6k req/s (+80%, oha -n 1M -c 100) on the same app, Content-Length restored. Genuine streams are unaffected.
nathanwhit
force-pushed
the
perf/fetch-response-reconstruction
branch
from
June 25, 2026 05:00
c366545 to
3b40f00
Compare
…-length bodies The static-body recovery in extractBody recovered any body materialized via the .body getter, including a Uint8Array buffered from a body whose length was unknown (e.g. a chunked request body forwarded through fetch). Recovering it to a static Uint8Array made fetch emit a Content-Length at send time. Gate recovery to length-known sources: strings (byte length is genuinely known, just deferred) and Uint8Arrays whose length was recorded in staticBodyLength. Fixes specs::run::fetch_forward_static_request_body_length.
…overy new Response(s).body is now recovered to a fixed-length body, so it would be sent with a Content-Length and skip the chunked streaming path this test exercises (its reader uses chunkedBodyReader). Build a genuinely-opaque stream instead.
…l/cancel The lazy ReadableStream materialized from a static Response/Request body (HWM 0, custom pull/cancel) passed plain functions that return undefined as its pull and cancel algorithms. `createReadableStream` requires these to return promises: `readableStreamCancel` calls `.then` on the cancel result directly, and the controller's pull machinery does the same on the pull result. This made `response.body.cancel()` throw "Promise.prototype.then called on incompatible receiver undefined" instead of cancelling, breaking the WPT response-stream-disturbed-4 cases. The pull path threw the same error too, but it was silently swallowed by the start-promise try/catch after the chunk had already been enqueued, so reads happened to work. Return promises from both algorithms (matching the noop/noopAsync split 06_streams.js itself uses).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A trivial Hono app served through
Deno.serveleft a lot of throughput on the table and emittedtransfer-encoding: chunkedwhere a fixedContent-Lengthwas possible:Profiling (
samply+--v8-flags=--perf-basic-prof) pinned the overhead to the middleware'sc.header()call, which afterawait next()runsnew Response(oldResponse.body, oldResponse)to tweak a header — a very common framework pattern. That exposed three avoidable costs, fixed here:Static body downgraded to a stream. Reading
.bodyturns the fast string body into aReadableStream, so the response is served via the streaming/chunked path — 3__sendtosyscalls/req and noContent-Length(~30% of main-thread time).extractBodynow recovers the original static body when aResponse/Requestis reconstructed from an undisturbed, static-backed stream, restoring the single-write fast static path. Genuine streams (and any read/locked stream) are untouched.Headers re-validated through webidl.
fillHeaderListsent the already-validatedHeadersinit object through the full webidlsequence<sequence<ByteString>>converter, re-validating every entry (~22% of main-thread time after fix Fix Small Typo: privlaged → privileged #1). It now copies aHeadersinstance's entries directly.Eager body-stream materialization. The
.bodygetter eagerly built and UTF-8-encoded aReadableStreamthat recovery immediately discards. It's now materialized lazily (high-water-mark 0, via the internalcreateReadableStreamthat also skips the webidlUnderlyingSourceconversion), so the encode only happens on an actual read.Result
oha -n 1000000 -c 100 --disable-compression,--profile=release-lite:+80% on the same app, with
Content-Lengthrestored. The remaining cost is dominated by raw socket I/O (__sendto+__recvfrom≈ 42% of main-thread time) and the inherent cost of constructingResponse/Headersobjects in JS.Behavior change
Reconstructing a
Response/Requestfrom another body's stream (new Response(other.body, other)) and serving/consuming it now yields aContent-Lengthinstead of chunked encoding when the source stream was static-backed and unread. As a side effect, consuming the reconstructed body no longer disturbs the original stream (the bytes are identical either way). GenuineReadableStreambodies are unaffected.Tests
ext/fetch/22_body.js/ext/fetch/20_headers.jschanges.tests/unit/serve_test.ts: thestream()helper now builds a genuinely-opaque stream so the existing length/chunked tests still exercise the chunked path; addedrecoveredStaticStreamBodycovering the new fast path.unit::{streams,body,headers,response,request,fetch,serve}_testall pass;tools/format.js+tools/lint.js --jsclean.