From 780548ca79d70e382bf492c115abc5e8f2295fd5 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 30 Sep 2026 15:06:51 +0200 Subject: [PATCH 1/2] http: avoid dictionary-mode objects in responses `setHeader()` stored headers in a `{ __proto__: null }` literal and `OutgoingMessage` let EventEmitter create the same literal for its listener table. V8 creates null-prototype literals in dictionary mode, so every response paid for a hash table allocation on the first `setHeader()`/`on()` call and for dictionary lookups on every header access, including the `for...in` in `_storeHeader()`. Use a fast-mode object with an empty null-prototype chain for the headers, and preset the listener table with the common events, as streams already do. Hello-world server (`res.setHeader()` + `res.end()`), one core: 40.9k -> 50.0k req/s (+22%); CPU 24.4 -> 20.0 us/req; 4374 -> 3905 B of young-gen allocation per request. Responses are byte-identical. Signed-off-by: Matteo Collina --- lib/_http_outgoing.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index f6713d7b06a4..2ca455eb5566 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -87,6 +87,15 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => { }); const kCorked = Symbol('corked'); + +// Container for the headers set through `setHeader()`, keyed by the +// lower-cased header name. It has no enumerable or inherited properties, like +// a `{ __proto__: null }` literal, but instances are fast-mode objects: V8 +// creates null-prototype literals in dictionary mode, which costs a hash table +// allocation per response and slower property access for every header. +class OutgoingHeaders {} +ObjectSetPrototypeOf(OutgoingHeaders.prototype, null); +delete OutgoingHeaders.prototype.constructor; const kSocket = Symbol('kSocket'); const kChunkedBuffer = Symbol('kChunkedBuffer'); const kChunkedLength = Symbol('kChunkedLength'); @@ -124,6 +133,18 @@ function OutgoingMessage(options) { return deprecateInstantiation(OutgoingMessage, 'DEP0195', options); } + // Preset the listener table with the events that are commonly listened + // for, mirroring streams. The presence of the properties keeps `_events` + // in fast mode: the first `on()` call would otherwise have to allocate a + // dictionary for the `{ __proto__: null }` object created by EventEmitter. + this._events ??= { + close: undefined, + error: undefined, + finish: undefined, + prefinish: undefined, + drain: undefined, + }; + Stream.call(this); // Queue that holds all currently pending data, until the response will be @@ -805,7 +826,7 @@ OutgoingMessage.prototype.setHeader = function setHeader(name, value) { let headers = this[kOutHeaders]; if (headers === null) - this[kOutHeaders] = headers = { __proto__: null }; + this[kOutHeaders] = headers = new OutgoingHeaders(); headers[name.toLowerCase()] = [name, value]; return this; From 3aacac4a3303c9c587f104d2a2553aca5a8e57b9 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 30 Sep 2026 16:51:41 +0200 Subject: [PATCH 2/2] benchmark: add implicit-framing case to http/set-header The existing `setHeader` and `setHeaderWH` cases set `Content-Length` explicitly, and `normal` passes every header to `writeHead()`. The very common handler shape `res.setHeader(...)` + `res.end(body)`, where `end()` derives the framing, exercises a different path and was not covered. Signed-off-by: Matteo Collina --- benchmark/fixtures/simple-http-server.js | 6 ++++++ benchmark/http/set-header.js | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/benchmark/fixtures/simple-http-server.js b/benchmark/fixtures/simple-http-server.js index fb5b0d87c16e..808ee7e4b2dc 100644 --- a/benchmark/fixtures/simple-http-server.js +++ b/benchmark/fixtures/simple-http-server.js @@ -114,6 +114,12 @@ module.exports = http.createServer((req, res) => { else res.writeHead(status, { 'Content-Length': len.toString() }); break; + case 'setHeaderImplicit': + // Let end() derive the message framing: no Content-Length or + // Transfer-Encoding header and no writeHead() call. + res.statusCode = status; + res.setHeader('Content-Type', 'text/plain'); + break; default: if (chunkedEnc) { res.writeHead(status, { diff --git a/benchmark/http/set-header.js b/benchmark/http/set-header.js index 8bf00b84ad0a..0936c1309e2d 100644 --- a/benchmark/http/set-header.js +++ b/benchmark/http/set-header.js @@ -2,7 +2,7 @@ const common = require('../common.js'); const bench = common.createBenchmark(main, { - res: ['normal', 'setHeader', 'setHeaderWH'], + res: ['normal', 'setHeader', 'setHeaderWH', 'setHeaderImplicit'], duration: 5, }); @@ -15,6 +15,7 @@ const c = 50; // normal: writeHead(status, {...}) // setHeader: statusCode = status, setHeader(...) x2 // setHeaderWH: setHeader(...), writeHead(status, ...) +// setHeaderImplicit: statusCode = status, setHeader(...) x1, framing left to end() function main({ res, duration }) { const server = require('../fixtures/simple-http-server.js') .listen(0)