Skip to content

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

Open
anonrig wants to merge 4 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 4 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).
  • Scan IncomingMessage.rawHeaders for Content-Length /
    Transfer-Encoding so optimizeEmptyRequests does not force
    req.headers construction.

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):

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).

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).

Local: 779/779 test-http* / test-https* / test-http2* passed
against a release build of this branch.

@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.

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.

3 participants