Skip to content

http,http2: speed up header validation and write coalescing - #65332

Open
anonrig wants to merge 6 commits into
nodejs:mainfrom
anonrig:cursor/http-http2-perf-f24c
Open

http,http2: speed up header validation and write coalescing#65332
anonrig wants to merge 6 commits into
nodejs:mainfrom
anonrig:cursor/http-http2-perf-f24c

Conversation

@anonrig

@anonrig anonrig commented Aug 16, 2026

Copy link
Copy Markdown
Member

This speeds up the HTTP/1 and HTTP/2 hot paths that show up in
writeHead()/end(), small-body writes, and HTTP/2 header serialization.

HTTP/1

  • Use the token lookup table for names of length <= 10 (Connection,
    Keep-Alive). Longer names stay on the regex path; a JS table over
    those lengths is slower than V8's regex.
  • Cache OutgoingMessage lenient-validation and skip statusMessage
    validation for the built-in reason phrases.
  • Reuse prebuilt HTTP/1.1 <code> <reason>\r\n status lines.
  • Avoid toLowerCase() for the common Title-Case / lowercase spellings
    of Connection, Content-Length, Transfer-Encoding, and friends.
  • Coalesce headers with small Buffer bodies into one socket.write().
  • When res.end(chunk) is used with Transfer-Encoding: chunked and
    headers have not been flushed, send headers + last chunk + terminator
    in a single write (headers copied as latin1 so obs-text is preserved).
  • Keep incoming header name/value bytes in a packed Buffer. rawHeaders
    / headers materialize JS strings only when read. The default server
    Host / Expect / body-header checks use flag bits and do not build
    req.headers.
  • IncomingMessage._dump() / dumped bodies skip Buffer::Copy via
    parser.setSkipBody().

HTTP/2

  • Skip toLowerCase() when header names are already lowercase.
  • Hoist buildNgHeaderString processing off a per-call closure.
  • Use a Set for sensitive / strict single-value header checks.
  • Reserve outgoing session storage while gathering
    nghttp2_session_mem_send chunks.

These are incremental, behavior-preserving changes. They do not move
end-to-end benchmark/http/simple.js by 50%. Local wrk numbers vs
upstream/main (same machine, release build, 2x 5s) for the JS write /
validation work:

config main this PR delta
buffer, len=4, chunked, c=50 84.6k req/s 91.2k +7.9%
bytes, len=4, chunked, c=50 78.2k 78.3k +0.1%
buffer, len=1024, chunked, c=50 73.4k 77.4k +5.4%
bytes, len=1024, content-length, c=50 77.0k 81.4k +5.8%

check_is_http_token for Connection is about +21%. Header-value
validation stays on regex (a JS Uint8Array scan was 30–50% slower).

The packed-header follow-up keeps bytes off the JS string heap until
something actually reads rawHeaders / headers. A per-request native
BaseObject was slower than creating the string array, so the bytes live
in one Buffer with flag bits for Host / Expect / body headers.

Tests

  • Extra checkInvalidHeaderChar cases in test/parallel/test-http-common.js.
  • test/parallel/test-http-chunked-end-coalesce.js checks the wire
    format of coalesced chunked end() (string, buffer, UTF-8, trailers,
    unusual Content-Length casing, latin1 header values).
  • test/parallel/test-http-native-headers.js checks lazy Host / Expect
    lookups and materialization on req.headers access.

Local: 405/405 test/parallel/test-http-* (HTTP/1) passed against a
release build of the packed-headers tree. Earlier 776/776
test-http* / test-https* / test-http2* passed on the same branch
before the Buffer packing tweak.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/http
  • @nodejs/http2
  • @nodejs/net

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Aug 16, 2026
Replace regex-based header token/value checks with byte lookup
tables, cache OutgoingMessage lenient-validation, and coalesce
headers with small Buffer bodies into a single socket write.

Scan IncomingMessage rawHeaders for Content-Length and
Transfer-Encoding so optimizeEmptyRequests does not force
req.headers construction.

On the HTTP/2 path, skip toLowerCase for already-lowercase names,
use a Set for sensitive/single-value header checks, and reserve
outgoing session storage to avoid reallocs while gathering
nghttp2 frames.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
Send headers, the last chunk, and the chunked terminator in a single
write() when res.end() is used with Transfer-Encoding: chunked.

Reuse prebuilt HTTP/1.1 status lines for default reason phrases, skip
toLowerCase() on common outgoing header names, and hoist HTTP/2 header
serialization off the per-call closure.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
@anonrig
anonrig force-pushed the cursor/http-http2-perf-f24c branch from 76c8e14 to 62327c4 Compare August 16, 2026 18:59
The chunked end() fast path concatenated headers with the body and
wrote the result using the body encoding. That re-encoded obs-text
header values as UTF-8 and reduced corked res.end() to a single
socket.write(), which broke test-http-server-non-utf8-header and
test-http-response-cork.

Copy headers as latin1 into the combined buffer, and accept a single
write after uncork.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
JS lookup tables were slower than V8's regex for header values and
for token names longer than ~10 bytes. Restore the regex path for
those cases, and use the table for names of length <= 10 so
Connection / Keep-Alive stay on the faster path.

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

anonrig commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Local results from a release build of this branch vs upstream/main on the same machine (4 cores).

Correctness

  • 779/779 test-http* / test-https* / test-http2* (parallel + sequential + pummel + async-hooks) passed.
  • The earlier CI failures (test-http-response-cork, test-http-server-non-utf8-header) were from writing coalesced chunked headers with the body encoding. Fixed by copying headers as latin1 and relaxing the cork test to mustCallAtLeast.

benchmark/http/simple.js (wrk, duration=5, c=50, 2 runs)

type len encoding main req/s PR req/s delta
buffer 4 chunked 84,567 91,239 +7.9%
bytes 4 chunked 78,179 78,271 +0.1%
buffer 1024 chunked 73,366 77,353 +5.4%
bytes 1024 content-length 76,980 81,406 +5.8%

Micros (3 runs, ops/s)

A JS lookup table for all token lengths and for header-value checks was a regression (V8 regex is faster past ~10 bytes). Latest commit keeps regex for values and for tokens longer than 10, and uses the table for Connection / Keep-Alive:

bench main PR delta
check_is_http_token Connection 44.2M 53.7M +21%
check_is_http_token Content-Length 37.8M 36.6M ~noise
check_invalid_header_char keep-alive 51.3M 49.0M ~noise
set_header Connection 12.5M 12.8M +2%

HTTP/2 headers.js was within noise (−7% to +0%).

So the measurable end-to-end win is the small-Buffer / chunked end() coalescing, on the order of 5–8%, not 50%. I am not claiming a 50% HTTP/HTTP2 speedup from this PR.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes Node.js HTTP/1.1 and HTTP/2 hot paths around header validation/serialization and write coalescing to reduce per-request overhead in writeHead()/end() and small-body responses.

Changes:

  • HTTP/1: faster header-name token validation, cached lenient-validation, reused prebuilt status lines, avoided common-case toLowerCase(), and added header/body write coalescing (including a combined chunked end() write).
  • HTTP/1 server: avoid forcing req.headers construction when checking for Content-Length / Transfer-Encoding by scanning rawHeaders.
  • HTTP/2: reduce lowercasing overhead in header processing and reserve outgoing send storage to reduce reallocations.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/parallel/test-http-response-cork.js Updates expectations to allow fewer socket.write() calls due to new coalescing behavior.
test/parallel/test-http-common.js Adds more checkInvalidHeaderChar() test cases for strict vs lenient validation.
test/parallel/test-http-chunked-end-coalesce.js New test validating wire format when chunked res.end() is coalesced into a single write.
src/node_http2.cc Reserves outgoing HTTP/2 storage in send path to reduce reallocations.
lib/internal/http2/util.js Refactors nghttp2 header string building to reduce lowercasing/closure overhead and uses Set for strict single-value checks.
lib/internal/http2/core.js Avoids unconditional toLowerCase() in response header array scanning.
lib/_http_server.js Prebuilds HTTP/1.1 status lines and avoids unnecessary statusMessage validation for defaults; uses req._hasBodyHeaders() for optimizeEmptyRequests.
lib/_http_outgoing.js Coalesces headers with small bodies; adds combined chunked end() fast path; caches lenient-header-validation result; reduces common-case toLowerCase().
lib/_http_incoming.js Adds IncomingMessage.prototype._hasBodyHeaders() scanning rawHeaders to avoid constructing req.headers.
lib/_http_common.js Speeds up checkIsHttpToken() by using a lookup table for short names and regex for longer ones.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/_http_outgoing.js
Comment on lines 1264 to +1276
if (chunk) {
if (this.finished) {
onError(this,
new ERR_STREAM_WRITE_AFTER_END(),
typeof callback !== 'function' ? nop : callback);
return this;
}

if (this[kSocket]) {
this[kSocket].cork();
}

write_(this, chunk, encoding, null, true);
write_(this, chunk, encoding, onFinish.bind(undefined, this), true);
Pass NativeHttpHeaders from the parser instead of a JS string
array. IncomingMessage materializes rawHeaders/headers only when
read. Host/Expect/body-header checks use C++ has/get.
Skip Buffer::Copy for dumped bodies via parser.setSkipBody().

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
Store header name/value bytes in a packed Buffer instead of a
per-request native BaseObject or JS string array. IncomingMessage
materializes rawHeaders only when read. Host/Expect/body-header
checks use flag bits so the default server path never creates
header strings. Dumped bodies skip Buffer::Copy via setSkipBody().

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

anonrig commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Follow-up: incoming header bytes now stay off the JS string heap until something actually reads them.

kOnHeadersComplete passes a packed Buffer (magic + count + flags + name/value bytes) instead of a JS string array or a per-request native BaseObject. IncomingMessage.rawHeaders / headers materialize strings only on first read. The default HTTP/1.1 server Host / Expect / Content-Length / Transfer-Encoding checks use flag bits written while packing, so simple.js-style handlers that never touch req.headers do not create header strings. _dump() sets parser.setSkipBody(true) so dumped bodies skip Buffer::Copy.

A first attempt used a NativeHttpHeaders BaseObject per request (Function::NewInstance + MakeWeak). That was slower than creating the JS string array — especially with many headers — so it was replaced with one Buffer.

test/parallel/test-http-native-headers.js covers the lazy Host check (including 400 without Host) and materialization. 405/405 test/parallel/test-http-* passed on the packed-buffer tree.

I am not updating the simple.js wrk table from this environment; run-to-run noise here is large enough to hide a real single-digit delta. The earlier +5–8% numbers are for the write/validation commits, not a claim that packed headers moved end-to-end simple.js by that much (wrk GETs have no body, and the handler never reads req.headers).

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

Labels

lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants