Skip to content

perf(ext/http): coalesce chunked response writes into a single write#35523

Merged
nathanwhit merged 4 commits into
denoland:mainfrom
nathanwhit:perf/http-coalesce-chunked-writes
Jun 26, 2026
Merged

perf(ext/http): coalesce chunked response writes into a single write#35523
nathanwhit merged 4 commits into
denoland:mainfrom
nathanwhit:perf/http-coalesce-chunked-writes

Conversation

@nathanwhit

Copy link
Copy Markdown
Member

Summary

Deno.serve responses whose body is a ReadableStream (sent Transfer-Encoding: chunked) regressed in throughput when the HTTP/1 layer was rewritten: the chunked path emits the head, each body chunk, and the terminating 0\r\n\r\n as three separate write syscalls, where it previously coalesced them into one. The fixed/Content-Length path was unaffected (it already coalesces head+body into a single write), so the regression is specific to streamed/chunked responses — which is what shows up as "streaming got slower" in the runtime benchmarks (a header-mutating Hono middleware re-emits the response as a ReadableStream, so it rides this path).

Measured with a write/sendto interposer, 30k requests, 100-byte body:

path before after
chunked (ReadableStream) 3 sendto/req 1 sendto/req
fixed (Content-Length) 1 sendto/req 1 sendto/req

Fix

Buffer a chunked response into the connection's existing per-request scratch buffer (SharedScratch::write_buf) and flush it once, instead of writing each piece straight to the socket:

  • poll_start_chunked_response_with leaves the head in write_buf (deferred) for Chunked responses. NoBody / CloseDelimited (HTTP/1.0) still flush the head immediately, since they have no chunk framing to coalesce behind.
  • poll_write_response_chunk_with appends each chunk's framing to write_buf, flushing only once it crosses CHUNKED_FLUSH_THRESHOLD (64 KiB) so memory stays bounded for large/long-lived bodies.
  • poll_finish_response_with appends the terminator, flushes once, then shrinks write_buf back to its default capacity so a connection that served one large response doesn't retain an oversized buffer.
  • The streaming drivers flush the buffer when the body source has nothing ready (Poll::Pending), so incremental/SSE responses are still delivered promptly; only chunks that are available synchronously coalesce.

No new per-connection allocation — it reuses the buffer that already exists for building response frames.

Results

oha -c 100 --disable-compression, --profile=release-lite, same build for both columns:

before after
chunked stream rps 75,643 102,024 (+35%)
fixed rps ~145k ~145k (unchanged)
stream RSS @ 400 rps 60 MB 60 MB (unchanged)

Tests

  • deno_http_h1 unit tests (which assert byte-exact chunked output, including partial/fragmented writes and trailers) pass.
  • unit::serve_test and unit::http_test pass.

A Deno.serve response with a ReadableStream body (Transfer-Encoding: chunked) emitted the head, body chunk, and terminator as three separate write syscalls; the fixed/Content-Length path coalesces head+body into one. This buffers the chunked path into the connection's existing per-request scratch buffer and flushes once.

poll_start_chunked_response_with defers the head for Chunked responses (NoBody/CloseDelimited still flush immediately); poll_write_response_chunk_with appends each chunk's framing, flushing only past a 64 KiB threshold; poll_finish_response_with appends the terminator, flushes once, and shrinks the buffer back to its default capacity. The streaming drivers flush when the body source returns Pending so incremental/SSE responses stay prompt.

chunked: 3 -> 1 write/req; stream throughput +35% (75.6k -> 102k rps, 100-byte body); fixed path and idle RSS unchanged. No new per-connection allocation.
Deferring the chunked response head (to coalesce it with the body) meant a body stream that errored before any flush left the head unsent, so the client saw a connection close before headers instead of the committed 200 + truncated body. Flush buffered bytes best-effort on a mid-stream body error, matching the prior eager-write behavior. Fixes specs::serve::stream_body_error.
…rites

The duplex/FragmentIo-backed h1 tests always accept a full buffer in one
poll_write, so they never exercised the buffered chunked path's partial-flush
resume (write_flushed) or the buffered re-poll guard. Add a ShortWriteIo sink
that caps bytes per write and periodically returns Pending, plus two tests: a
byte-at-a-time coalesced response, and a chunk past CHUNKED_FLUSH_THRESHOLD that
flushes mid-stream under short writes.
Satisfies clippy::manual_is_multiple_of (-D clippy::all).
@nathanwhit
nathanwhit merged commit 87bc062 into denoland:main Jun 26, 2026
268 of 270 checks passed
nathanwhit added a commit that referenced this pull request Jun 30, 2026
…onse_with` (#35649)

## Issue details

See #35648

## Fix

Add missing `scratch.write_flushed = scratch.write_buf.len();` in
`poll_start_fixed_response_with`.

_Without it_, `poll_flush_write_buf` re-sends the response head as the
body when the `ReadableStream` source yields `Poll::Pending`. It mirrors
what `poll_start_chunked_response_with` _already_ does (introduced in
#35523)

## Verify

- Create `example.ts` (change path to your PNG file as needed) 
```ts
Deno.serve({ port: 9999 }, async (request) => {
  await request.body?.cancel();
  const path = "your-image.png";
  const { size } = await Deno.stat(path);
  const file = await Deno.open(path, { read: true });
  return new Response(file.readable, {
    headers: {
      "content-type": "image/png",
      "content-length": String(size),
    },
  });
});
```

- Run the example

```sh
deno run -A ./example.ts
Listening on http://0.0.0.0:9999/ (http://localhost:9999/)
```

- Check the output

**Before:**

```sh
curl -s http://localhost:9999 | head -c 8
HTTP/1.1
```

**Now:**

```sh
curl -s http://localhost:9999 | head -c 8
PNG
```


Fixes #35648

---------

Co-authored-by: Nathan Whitaker <[email protected]>
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