Skip to content

http: add native single-shot response builder#64393

Open
anonrig wants to merge 6 commits into
nodejs:mainfrom
anonrig:http-native-single-shot-response
Open

http: add native single-shot response builder#64393
anonrig wants to merge 6 commits into
nodejs:mainfrom
anonrig:http-native-single-shot-response

Conversation

@anonrig

@anonrig anonrig commented Jul 9, 2026

Copy link
Copy Markdown
Member

Summary

Add a C++ buildHttpMessage helper used by the HTTP/1.1 outgoing path so the common cases avoid repeated JavaScript string concatenation and multi-write overhead:

  • _storeHeader: builds the status/request line + header block natively (with JS fallback for special cases such as content-disposition latin1 / unique cookie joining).
  • ServerResponse.end() fast paths:
    • Headers not yet rendered: full message (headers + body) built in C++ and written once.
    • After writeHead(): tryFastFlushEnd flushes the prebuilt header + body without the full write_() / extra empty-write path (this is the benchmark/http/simple.js case).
  • Server-side native headers stay as Buffers until write time (no latin1 string copy on the hot path). ClientRequest still materializes a string for _header compatibility.
  • Complex encodings (hex, utf16le, ...), trailers, and fake/standalone sockets keep the existing JS path.

Public API and on-the-wire format are unchanged.

Benchmarks

Machine-local out/Release on the same host. Baseline measured before this change.

benchmark/http/simple.js (primary)

type=bytes len=4 chunks=1 c=50 chunkedEnc=0 duration=5
RPS
Baseline ~80,236
This PR (5 runs, latest tip) 120,087 / 122,461 / 116,360 / 122,196 / 123,654
Delta ~+45% to +54% (typical ~+50% around 120–123k)

Other simple.js variants (same tip):

Config RPS (3 runs)
chunkedEnc=1 (TE: chunked) 121k / 120k / 116k
len=1024 content-length 103k / 106k / 96k

Other microbenches

Workload RPS
end-vs-write-end method=end asc/64 c=50 duration=5 ~104–129k (run-to-run variance)
end-vs-write-end method=write asc/64 c=50 duration=5 ~106k
Custom keep-alive res.end('ok') only (50 clients, 3s) ~47–49k

Review feedback (document flags, no incidental C++ reformat, no Buffer.concat) is included; the common ASCII path still shows the large win vs baseline.

Tests

Full test/parallel/test-http* suite: 751/751 passed (pre-push on an earlier tip); targeted suite re-run after the latest speed-ups.

Test plan

  • tools/test.py test/parallel/test-http* --mode=release (751 passed)
  • Targeted cases: write-head, non-utf8 content-disposition, chunked 204/304, CONNECT, client default headers, outgoing-end-types, hex/utf16le first-chunk encodings, standalone ServerResponse
  • benchmark/http/simple.js before/after (and after review + further speed-ups)
  • CI on this PR

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/http
  • @nodejs/net

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. http Issues or PRs related to the http subsystem. http_parser Issues and PRs related to the HTTP Parser dependency or the http_parser binding. needs-ci PRs that need a full CI run. labels Jul 9, 2026
@anonrig anonrig requested review from Qard, mcollina, panva and ronag July 9, 2026 15:10
Serialize the common HTTP/1.1 header block (and optional body) in C++ so
_storeHeader and ServerResponse.end avoid repeated JS string
concatenation and multi-write path overhead. The public API and wire
format are unchanged; complex cases fall back to the existing JS path.

On benchmark/http/simple.js (bytes/4/1, 50 connections, 5s) this raised
throughput from ~80k to ~118-126k req/s (~+48% to +58%) on the same
machine.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
@anonrig anonrig force-pushed the http-native-single-shot-response branch from 6e25748 to 8a0057a Compare July 9, 2026 15:37

@mcollina mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing results!

Comment thread src/node_http_parser.cc Outdated
Comment thread lib/_http_outgoing.js Outdated
Comment thread lib/_http_outgoing.js Outdated
Comment thread lib/_http_outgoing.js Outdated
Revert incidental ConnectionsList reformatting in node_http_parser.cc,
document the buildHttpMessage flag and out-param slots in JS, and avoid
Buffer.concat when pairing headers with a body by queueing a separate
latin1 header write instead.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
@anonrig anonrig requested a review from mcollina July 9, 2026 16:15
Hot writeHead()+end() now flushes via tryFastFlushEnd instead of the
full write_()/extra empty write path. Server-side native headers stay as
Buffers (no latin1 string copy) until write time; the C++ builder writes
into a single malloc transferred to Buffer (no std::string + Copy).
Reuse a scratch flat-header array and a prebuilt 200 status line.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>

@mcollina mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

This needs a CITGM run and some validation it doesn't break express, fastify, koa, etc before shipping.

@mcollina mcollina added the notable-change PRs with changes that should be highlighted in changelogs. label Jul 9, 2026
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

The notable-change PRs with changes that should be highlighted in changelogs. label has been added by @mcollina.

Please suggest a text for the release notes if you'd like to include a more detailed summary, then proceed to update the PR description with the text or a link to the notable change suggested text comment. Otherwise, the commit will be placed in the Other Notable Changes section.

@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. label Jul 9, 2026
@mcollina

mcollina commented Jul 9, 2026

Copy link
Copy Markdown
Member

Also a benchmark CI run would be interesting.

@anonrig

anonrig commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

@anonrig

anonrig commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

@mcollina mcollina added the baking-for-lts PRs that need to wait before landing in a LTS release. label Jul 9, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Jul 9, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.07056% with 265 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.17%. Comparing base (8fec65d) to head (16b8474).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
lib/_http_outgoing.js 76.26% 161 Missing and 12 partials ⚠️
src/node_http_parser.cc 72.45% 41 Missing and 51 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #64393      +/-   ##
==========================================
- Coverage   90.25%   90.17%   -0.08%     
==========================================
  Files         741      741              
  Lines      241207   242329    +1122     
  Branches    45430    45733     +303     
==========================================
+ Hits       217698   218527     +829     
- Misses      15084    15298     +214     
- Partials     8425     8504      +79     
Files with missing lines Coverage Δ
src/node_http_parser.cc 81.18% <72.45%> (-3.68%) ⬇️
lib/_http_outgoing.js 89.31% <76.26%> (-8.31%) ⬇️

... and 30 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@mcollina mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs a few regression tests before landing. The risky area is not just performance; the native path must preserve the exact HTTP/1 framing behavior of the existing JS path.

Main cases I’d like covered:

  • res.end() with chunked framing but no body must still emit the terminating 0\r\n\r\n.
  • strictContentLength must still throw on the single res.end(chunk) fast path.
  • Trailer must keep the response chunked and must not be rewritten to Content-Length.
  • Transfer-Encoding: chunked;... must still be recognized as chunked framing.
  • Large Content-Length bookkeeping must not be truncated through native out-params.

Implementation note: native buffer bounds checks should avoid off + n <= est because that can overflow before the comparison; prefer subtraction-style checks like off <= est && n <= est - off or checked arithmetic.

Suggested regression file: test/parallel/test-http-outgoing-native-builder-regressions.js

'use strict';

const common = require('../common');
const assert = require('assert');
const http = require('http');
const net = require('net');

function rawRequest(handler, callback) {
  const server = http.createServer(common.mustCall(handler));

  server.listen(0, common.mustCall(() => {
    const socket = net.connect(server.address().port, common.mustCall(() => {
      socket.end('GET / HTTP/1.1\r\n' +
                 'Host: localhost\r\n' +
                 'Connection: close\r\n' +
                 '\r\n');
    }));

    socket.setEncoding('latin1');
    let raw = '';
    socket.on('data', (chunk) => raw += chunk);
    socket.on('end', common.mustCall(() => {
      server.close(common.mustCall(() => callback(raw)));
    }));
  }));
}

// Empty chunked responses must still terminate the chunked body.
rawRequest((req, res) => {
  res.setHeader('Transfer-Encoding', 'chunked');
  res.end();
}, common.mustCall((raw) => {
  assert.match(raw, /\r\nTransfer-Encoding: chunked\r\n/i);
  assert.match(raw, /\r\n0\r\n\r\n$/);
}));

// Advertising trailers requires chunked framing; do not rewrite to Content-Length.
rawRequest((req, res) => {
  res.setHeader('Trailer', 'X-Checksum');
  res.end('ok');
}, common.mustCall((raw) => {
  assert.match(raw, /\r\nTrailer: X-Checksum\r\n/i);
  assert.match(raw, /\r\nTransfer-Encoding: chunked\r\n/i);
  assert.doesNotMatch(raw, /\r\nContent-Length:/i);
  assert.match(raw, /\r\n2\r\nok\r\n0\r\n\r\n$/);
}));

// Transfer-Encoding parameters are valid transfer-coding parameters and must
// still be recognized as chunked framing.
rawRequest((req, res) => {
  res.setHeader('Transfer-Encoding', 'chunked;foo=bar');
  res.end('ok');
}, common.mustCall((raw) => {
  assert.match(raw, /\r\nTransfer-Encoding: chunked;foo=bar\r\n/i);
  assert.doesNotMatch(raw, /\r\nContent-Length:/i);
  assert.match(raw, /\r\n2\r\nok\r\n0\r\n\r\n$/);
}));

// strictContentLength must be enforced for the single res.end(chunk) path too.
{
  const server = http.createServer(common.mustCall((req, res) => {
    res.strictContentLength = true;
    res.setHeader('Content-Length', '4');

    assert.throws(() => res.end('hello'), {
      code: 'ERR_HTTP_CONTENT_LENGTH_MISMATCH'
    });

    res.destroy();
    server.close(common.mustCall());
  }));

  server.listen(0, common.mustCall(() => {
    const socket = net.connect(server.address().port, common.mustCall(() => {
      socket.end('GET / HTTP/1.1\r\n' +
                 'Host: localhost\r\n' +
                 'Connection: close\r\n' +
                 '\r\n');
    }));

    socket.resume();
    socket.on('close', common.mustCall());
  }));
}

// Do not truncate Content-Length bookkeeping through native out-params.
{
  const server = http.createServer(common.mustCall((req, res) => {
    res.setHeader('Content-Length', '4294967296');
    res.writeHead(200);

    assert.strictEqual(res._contentLength, 4294967296);

    res.destroy();
    server.close(common.mustCall());
  }));

  server.listen(0, common.mustCall(() => {
    const socket = net.connect(server.address().port, common.mustCall(() => {
      socket.end('GET / HTTP/1.1\r\n' +
                 'Host: localhost\r\n' +
                 'Connection: close\r\n' +
                 '\r\n');
    }));

    socket.resume();
    socket.on('close', common.mustCall());
  }));
}

Run with:

tools/test.py test/parallel/test-http-outgoing-native-builder-regressions.js

@pimterry

Copy link
Copy Markdown
Member

+1 on the test concerns, notably we do already have a bunch of existing tests around all this stuff, so it would be really interesting to know which of those are now using the fast path or still on the old path after this change, because either:

  • The existing tests aren't covering this change, and we need lots more testing here because this PR has none.
  • Or the existing tests all now use the fast path (great!) but this means we no longer have any tests covering the existing slow path - we should add some back.

A setup covering a bunch of the important cases structured such that they can run in fast path or slow path mode, to verify both paths against the same set of tests would be fantastic, if that's doable (or at least for some subset of the test cases). My biggest concern here is that these paths will diverge and create very confusing issues, so any way to bind them closer together would be great.

Address review feedback: keep Trailer/TE-with-params on the chunked
path, emit the empty chunked terminator from the single-shot fast path,
enforce strictContentLength on res.end(chunk), avoid truncating large
Content-Length values via Float64 out-params, and use overflow-safe
buffer bounds checks. Add dual-path regression coverage.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
@anonrig anonrig requested a review from mcollina July 10, 2026 14:06
anonrig added 2 commits July 10, 2026 11:38
Keep writeHead()/_storeHeader() on the legacy JS path: the C++ builder
was a net regression for small-header (simple.js) and many-header
(headers.js) workloads. Reserve buildHttpMessage for tryFastEnd()
single-shot end(body) with bodies up to 16KiB, and never concatenate
larger payloads into a combined headers+body write.

Also type buildHttpMessage on the http_parser binding.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
Avoid cork/uncork on single-write flushes, combine small Buffer bodies
with the header into one write for the simple.js type=buffer path, and
use corked dual writes (not uncorked queued writes) for large string
bodies so end-vs-write-end stays competitive without O(n) copies.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
@mcollina

Copy link
Copy Markdown
Member

CI is red

@anonrig

anonrig commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

CI is red

Applying your suggestion caused a regression in performance. Trying to fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

baking-for-lts PRs that need to wait before landing in a LTS release. c++ Issues and PRs that require attention from people who are familiar with C++. http_parser Issues and PRs related to the HTTP Parser dependency or the http_parser binding. http Issues or PRs related to the http subsystem. needs-ci PRs that need a full CI run. notable-change PRs with changes that should be highlighted in changelogs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants