Skip to content

perf(ext/fetch): fast-path Response reconstruction#35495

Merged
nathanwhit merged 4 commits into
denoland:mainfrom
nathanwhit:perf/fetch-response-reconstruction
Jun 26, 2026
Merged

perf(ext/fetch): fast-path Response reconstruction#35495
nathanwhit merged 4 commits into
denoland:mainfrom
nathanwhit:perf/fetch-response-reconstruction

Conversation

@nathanwhit

@nathanwhit nathanwhit commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

A trivial Hono app served through Deno.serve left a lot of throughput on the table and emitted transfer-encoding: chunked where a fixed Content-Length was possible:

const app = new Hono();
app.use("*", async (c, next) => {
  await next();
  c.header("x-powered-by", "hono");
});
app.get("/", (c) => c.json({ hello: "world", framework: "hono" }));
Deno.serve({ port, hostname: "127.0.0.1" }, app.fetch);

Profiling (samply + --v8-flags=--perf-basic-prof) pinned the overhead to the middleware's c.header() call, which after await next() runs new Response(oldResponse.body, oldResponse) to tweak a header — a very common framework pattern. That exposed three avoidable costs, fixed here:

  1. Static body downgraded to a stream. Reading .body turns the fast string body into a ReadableStream, so the response is served via the streaming/chunked path — 3 __sendto syscalls/req and no Content-Length (~30% of main-thread time). 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. Genuine streams (and any read/locked stream) are untouched.

  2. Headers re-validated through webidl. fillHeaderList sent the already-validated Headers init object through the full webidl sequence<sequence<ByteString>> converter, re-validating every entry (~22% of main-thread time after fix Fix Small Typo: privlaged → privileged #1). It now copies a Headers instance's entries directly.

  3. Eager body-stream materialization. The .body getter eagerly built and UTF-8-encoded a ReadableStream that recovery immediately discards. It's now materialized lazily (high-water-mark 0, via the internal createReadableStream that also skips the webidl UnderlyingSource conversion), so the encode only happens on an actual read.

Result

oha -n 1000000 -c 100 --disable-compression, --profile=release-lite:

req/s encoding
before 54,899 chunked
+ static-body recovery 79,207 content-length
+ Headers fast path 91,807 content-length
+ lazy body stream 98,637 content-length

+80% on the same app, with Content-Length restored. The remaining cost is dominated by raw socket I/O (__sendto + __recvfrom ≈ 42% of main-thread time) and the inherent cost of constructing Response/Headers objects in JS.

Behavior change

Reconstructing a Response/Request from another body's stream (new Response(other.body, other)) and serving/consuming it now yields a Content-Length instead 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). Genuine ReadableStream bodies are unaffected.

Tests

  • ext/fetch/22_body.js / ext/fetch/20_headers.js changes.
  • tests/unit/serve_test.ts: the stream() helper now builds a genuinely-opaque stream so the existing length/chunked tests still exercise the chunked path; added recoveredStaticStreamBody covering the new fast path.
  • unit::{streams,body,headers,response,request,fetch,serve}_test all pass; tools/format.js + tools/lint.js --js clean.

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
nathanwhit force-pushed the perf/fetch-response-reconstruction branch from c366545 to 3b40f00 Compare June 25, 2026 05:00
…-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).
@nathanwhit
nathanwhit merged commit e887f38 into denoland:main Jun 26, 2026
136 checks passed
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