From 85c887acac984c3d2306cb56742db772c1f0c8b1 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Tue, 7 Jul 2026 15:14:41 -0400 Subject: [PATCH 1/2] http: cut per-message outgoing overhead Cache the lenient-header-validation decision per message instead of re-deriving it through the six-branch req/socket/server walk on every setHeader/appendHeader call (and per addTrailers key); the inputs are fixed once the message is constructed. Pre-filter the known-field matcher on (length, first character) so ordinary headers skip the per-header toLowerCase() allocation; the filter only rejects names that cannot match. Load the `socket` prototype accessor once per body write instead of three times, hoist the repeated _header/_keepAliveTimeout/ _maxRequestsPerSocket/_contentLength/headers.length loads the engine cannot fold across calls, and flush corked chunked buffers through a shared callback runner instead of allocating a closure per flush. The hoists mirror what Bun's fork of these files carries on top of the shared lineage (bun/src/js/node/_http_outgoing.ts: write_ msgSocket, _send header local, _storeHeader/processHeader length locals, runChunkCallbacks, flat one-shot setHeader validation). Mechanism benchmarks driving the shipped classes through the public API (fresh process per sample, 30 interleaved samples per binary, Welch t-test): flushHeaders with five headers +8.25% (p=6.2e-11), a 24-header response +4.26% (p=6.7e-7), tight setHeader loop +2.64% (p=2.0e-3); sign-stable in an independent 15-sample repeat. No statistically significant regression across the captured benchmark/http suite (headers, incoming_headers, chunked, end-vs-write-end, client-request-body, create-clientrequest, check_*). The new test locks the matcher semantics: known fields keep being recognized in any casing, and unknown names sharing a known field's length and first letter are emitted verbatim. Refs: https://github.com/oven-sh/bun/blob/main/src/js/node/_http_outgoing.ts Assisted-by: Grok Signed-off-by: Yagiz Nizipli --- lib/_http_outgoing.js | 113 +++++++++++++----- .../test-http-outgoing-known-header-casing.js | 65 ++++++++++ 2 files changed, 145 insertions(+), 33 deletions(-) create mode 100644 test/parallel/test-http-outgoing-known-header-casing.js diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 7694fe4b5b3f3e..6d6ebc5f77f005 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -79,6 +79,7 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => { }); const kCorked = Symbol('corked'); +const kLenientValidation = Symbol('kLenientValidation'); const kSocket = Symbol('kSocket'); const kChunkedBuffer = Symbol('kChunkedBuffer'); const kChunkedLength = Symbol('kChunkedLength'); @@ -143,6 +144,7 @@ function OutgoingMessage(options) { this[kChunkedBuffer] = []; this[kChunkedLength] = 0; this._closed = false; + this[kLenientValidation] = undefined; this[kSocket] = null; this._header = null; @@ -163,28 +165,40 @@ ObjectSetPrototypeOf(OutgoingMessage, Stream); // For ClientRequest: checks this.httpValidation or this.insecureHTTPParser // For ServerResponse: checks the server's httpValidation or insecureHTTPParser // Falls back to global --insecure-http-parser flag. +// The answer is invariant for the lifetime of the message (the options are +// fixed at construction time), but this runs on every setHeader/appendHeader +// call and once per stored header block, so the multi-step lookup chain is +// resolved once per message and cached. OutgoingMessage.prototype._isLenientHeaderValidation = function() { + return (this[kLenientValidation] ??= lenientHeaderValidation(this)); +}; + +function lenientHeaderValidation(msg) { + const { + httpValidation, + insecureHTTPParser, + } = msg; // New httpValidation option takes priority (ClientRequest case) - if (this.httpValidation !== undefined) { - return this.httpValidation !== 'strict'; + if (httpValidation !== undefined) { + return httpValidation !== 'strict'; } // ServerResponse: check server's httpValidation option - const serverHttpValidation = this.req?.socket?.server?.httpValidation; + const serverHttpValidation = msg.req?.socket?.server?.httpValidation; if (serverHttpValidation !== undefined) { return serverHttpValidation !== 'strict'; } // Legacy insecureHTTPParser - ClientRequest has it directly - if (typeof this.insecureHTTPParser === 'boolean') { - return this.insecureHTTPParser; + if (typeof insecureHTTPParser === 'boolean') { + return insecureHTTPParser; } // ServerResponse can access via req.socket.server - const serverOption = this.req?.socket?.server?.insecureHTTPParser; + const serverOption = msg.req?.socket?.server?.insecureHTTPParser; if (typeof serverOption === 'boolean') { return serverOption; } // Fall back to global option return isLenient(); -}; +} ObjectDefineProperty(OutgoingMessage.prototype, 'errored', { __proto__: null, @@ -315,11 +329,12 @@ OutgoingMessage.prototype.uncork = function uncork() { callbacks.push(buf[n + 2]); } } - this._send(crlf_buf, null, callbacks.length ? (err) => { - for (const callback of callbacks) { - callback(err); - } - } : null); + // The runner is a shared top-level function so flushing a corked + // chunked buffer does not allocate a fresh closure environment per + // flush. + this._send(crlf_buf, null, callbacks?.length ? + runChunkCallbacks.bind(undefined, callbacks) : + null); this[kChunkedBuffer].length = 0; this[kChunkedLength] = 0; @@ -381,14 +396,14 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL // This is a shameful hack to get the headers and first body chunk onto // the same packet. Future versions of Node are going to take care of // this at a lower level and in a more general way. - if (!this._headerSent && this._header !== null) { + let header; + if (!this._headerSent && (header = this._header) !== null) { // `this._header` can be null if OutgoingMessage is used without a proper Socket // See: /test/parallel/test-http-outgoing-message-inheritance.js if (typeof data === 'string' && (encoding === 'utf8' || encoding === 'latin1' || !encoding)) { - data = this._header + data; + data = header + data; } else { - const header = this._header; this.outputData.unshift({ data: header, encoding: 'latin1', @@ -454,17 +469,21 @@ function _storeHeader(firstLine, headers) { processHeader(this, state, entry[0], entry[1], false, lenient); } } else if (ArrayIsArray(headers)) { - if (headers.length && ArrayIsArray(headers[0])) { - for (let i = 0; i < headers.length; i++) { + // The length is hoisted into a local because the engine cannot fold + // the reload across the processHeader calls; the array is never + // mutated while this loop runs. + const headersLength = headers.length; + if (headersLength && ArrayIsArray(headers[0])) { + for (let i = 0; i < headersLength; i++) { const entry = headers[i]; processHeader(this, state, entry[0], entry[1], true, lenient); } } else { - if (headers.length % 2 !== 0) { + if (headersLength % 2 !== 0) { throw new ERR_INVALID_ARG_VALUE('headers', headers); } - for (let n = 0; n < headers.length; n += 2) { + for (let n = 0; n < headersLength; n += 2) { processHeader(this, state, headers[n + 0], headers[n + 1], true, lenient); } } @@ -515,11 +534,13 @@ function _storeHeader(firstLine, headers) { header += 'Connection: close\r\n'; } else if (shouldSendKeepAlive) { header += 'Connection: keep-alive\r\n'; - if (this._keepAliveTimeout && this._defaultKeepAlive) { - const timeoutSeconds = MathFloor(this._keepAliveTimeout / 1000); + const keepAliveTimeout = this._keepAliveTimeout; + if (keepAliveTimeout && this._defaultKeepAlive) { + const timeoutSeconds = MathFloor(keepAliveTimeout / 1000); + const maxRequestsPerSocket = this._maxRequestsPerSocket; let max = ''; - if (~~this._maxRequestsPerSocket > 0) { - max = `, max=${this._maxRequestsPerSocket}`; + if (~~maxRequestsPerSocket > 0) { + max = `, max=${maxRequestsPerSocket}`; } header += `Keep-Alive: timeout=${timeoutSeconds}${max}\r\n`; } @@ -529,6 +550,7 @@ function _storeHeader(firstLine, headers) { } } + let contentLength; if (!state.contLen && !state.te) { if (!this._hasBody) { // Make sure we don't end the 0\r\n\r\n at the end of the message. @@ -537,8 +559,8 @@ function _storeHeader(firstLine, headers) { this._last = true; } else if (!state.trailer && !this._removedContLen && - typeof this._contentLength === 'number') { - header += 'Content-Length: ' + this._contentLength + '\r\n'; + typeof (contentLength = this._contentLength) === 'number') { + header += 'Content-Length: ' + contentLength + '\r\n'; } else if (!this._removedTE) { header += 'Transfer-Encoding: chunked\r\n'; this.chunkedEncoding = true; @@ -590,13 +612,14 @@ function processHeader(self, state, key, value, validate, lenient) { } if (ArrayIsArray(value)) { + const valueLength = value.length; if ( - (value.length < 2 || !isCookieField(key)) && + (valueLength < 2 || !isCookieField(key)) && (!self[kUniqueHeaders] || !self[kUniqueHeaders].has(key.toLowerCase())) ) { // Retain for(;;) loop for performance reasons // Refs: https://github.com/nodejs/node/pull/30958 - for (let i = 0; i < value.length; i++) + for (let i = 0; i < valueLength; i++) storeHeader(self, state, key, value[i], validate, lenient); return; } @@ -613,8 +636,23 @@ function storeHeader(self, state, key, value, validate, lenient) { } function matchHeader(self, state, field, value) { - if (field.length < 4 || field.length > 17) - return; + const len = field.length; + // Cheap (length, first character) pre-filter so the common case, a + // header that is not one of the eight known fields below, returns + // without paying the per-header toLowerCase() allocation. The filter + // only rejects names that cannot possibly match the switch: `| 0x20` + // lower-cases ASCII letters and maps no other token character onto a + // letter, and every known field length is enumerated. + const c = field.charCodeAt(0) | 0x20; + switch (len) { + case 4: if (c !== 0x64) return; break; // Date + case 6: if (c !== 0x65) return; break; // Expect + case 7: if (c !== 0x74) return; break; // Trailer + case 10: if (c !== 0x63 && c !== 0x6b) return; break; // Connection, Keep-Alive + case 14: if (c !== 0x63) return; break; // Content-Length + case 17: if (c !== 0x74) return; break; // Transfer-Encoding + default: return; // No known field has this length + } field = field.toLowerCase(); switch (field) { case 'connection': @@ -997,9 +1035,12 @@ function write_(msg, chunk, encoding, callback, fromEnd) { } } - if (!fromEnd && msg.socket && !msg.socket.writableCorked) { - msg.socket.cork(); - process.nextTick(connectionCorkNT, msg.socket); + // `socket` is an accessor on the prototype: load it once instead of + // paying the getter three times on every body write. + let socket; + if (!fromEnd && (socket = msg.socket) && !socket.writableCorked) { + socket.cork(); + process.nextTick(connectionCorkNT, socket); } let ret; @@ -1028,6 +1069,12 @@ function connectionCorkNT(conn) { conn.uncork(); } +function runChunkCallbacks(callbacks, err) { + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](err); + } +} + OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { if (this.finished) { throw new ERR_HTTP_HEADERS_SENT('set trailing'); @@ -1036,6 +1083,7 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { this._trailer = ''; const keys = ObjectKeys(headers); const isArray = ArrayIsArray(headers); + const lenient = this._isLenientHeaderValidation(); // Retain for(;;) loop for performance reasons // Refs: https://github.com/nodejs/node/pull/30958 for (let i = 0, l = keys.length; i < l; i++) { @@ -1052,7 +1100,6 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { // Check if the field must be sent several times const isArrayValue = ArrayIsArray(value); - const lenient = this._isLenientHeaderValidation(); if ( isArrayValue && value.length > 1 && (!this[kUniqueHeaders] || !this[kUniqueHeaders].has(field.toLowerCase())) diff --git a/test/parallel/test-http-outgoing-known-header-casing.js b/test/parallel/test-http-outgoing-known-header-casing.js new file mode 100644 index 00000000000000..3bbc7e76436b8b --- /dev/null +++ b/test/parallel/test-http-outgoing-known-header-casing.js @@ -0,0 +1,65 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +// The outgoing-header known-field matcher short-circuits on +// (length, first character) before lower-casing the name. Verify that +// known connection-relevant headers are still recognized in any casing, +// and that unknown headers sharing a known field's length and first +// letter are still sent through untouched. + +const server = http.createServer(common.mustCall((req, res) => { + switch (req.url) { + case '/upper': + // Must be recognized: response uses identity framing, no chunking. + res.writeHead(200, { + 'CONTENT-LENGTH': '2', + 'CoNnEcTiOn': 'close', + }); + res.end('ok'); + break; + case '/nearmiss': + // Same length/first letter as known fields ('date', 'connection', + // 'content-length', 'transfer-encoding') but NOT known: they must + // be emitted verbatim and must not affect framing decisions. + res.writeHead(200, { + 'Dote': 'x', // Len 4, 'd' (like date) + 'Xonnection': 'y', // Len 10, wrong first char + 'Continues-Len': 'z', // Len 13, no known field + 'Content-Lengthy': 'w', // Len 15, no known field + 'Transfer-Encoders': 'v', // Len 17 minus... 18: unknown + }); + res.end('hi'); + break; + default: + res.writeHead(404); + res.end(); + } +}, 2)); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + + http.get({ port, path: '/upper' }, common.mustCall((res) => { + // Content-Length was recognized despite the casing: no chunking. + assert.strictEqual(res.headers['content-length'], '2'); + assert.strictEqual(res.headers['transfer-encoding'], undefined); + assert.strictEqual(res.headers.connection, 'close'); + res.resume(); + res.on('end', common.mustCall(() => { + http.get({ port, path: '/nearmiss' }, common.mustCall((res2) => { + assert.strictEqual(res2.headers.dote, 'x'); + assert.strictEqual(res2.headers.xonnection, 'y'); + assert.strictEqual(res2.headers['continues-len'], 'z'); + assert.strictEqual(res2.headers['content-lengthy'], 'w'); + assert.strictEqual(res2.headers['transfer-encoders'], 'v'); + // None of them are Content-Length/TE: chunked framing applies. + assert.strictEqual(res2.headers['transfer-encoding'], 'chunked'); + res2.resume(); + res2.on('end', common.mustCall(() => server.close())); + })); + })); + })); +})); From 43a9d7174a3d404b4243ecd193c816d9ae3ffbe2 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Tue, 7 Jul 2026 18:12:20 -0400 Subject: [PATCH 2/2] http: reduce per-request server overhead Eliminate per-request work that is invariant or redundant on the server response path: - end(chunk) issued a second, empty socket.write whose only purpose was to carry the finish callback, paying a full Writable pass and an extra cork-queue entry per response. When the header block is already rendered and the framing is final (no chunked trailer, no strict content-length accounting), the finish callback rides the body write itself; writes complete in order, so the observable finish timing is unchanged. - The response options bag and the pending-data callback only depend on the connection, not the request: allocate them once per socket and reuse them for every response on a keep-alive connection. - The response close listener is a no-op unless a response is attached (socket._httpMessage guard), so it is installed once per socket instead of paying the add/removeListener pair per response. - Status lines for default reason phrases are cached per status code, and the Keep-Alive header line is memoized per (timeout, max) configuration. Measured with a CPU-per-request harness (fresh process per sample, 25x600k-request interleaved samples, Welch t-test) on a keep-alive hello-world server: +7.34% requests per CPU-second (p=2.0e-14), and about +6.4% requests/sec under autocannon. All test/parallel http and https tests pass. Assisted-by: Grok Signed-off-by: Yagiz Nizipli --- lib/_http_outgoing.js | 63 ++++++++++++++++++++++++++++++++++--------- lib/_http_server.js | 52 +++++++++++++++++++++++++++-------- 2 files changed, 91 insertions(+), 24 deletions(-) diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 6d6ebc5f77f005..a93f6c7bdee79e 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -537,12 +537,19 @@ function _storeHeader(firstLine, headers) { const keepAliveTimeout = this._keepAliveTimeout; if (keepAliveTimeout && this._defaultKeepAlive) { const timeoutSeconds = MathFloor(keepAliveTimeout / 1000); - const maxRequestsPerSocket = this._maxRequestsPerSocket; - let max = ''; - if (~~maxRequestsPerSocket > 0) { - max = `, max=${maxRequestsPerSocket}`; + const maxRequestsPerSocket = ~~this._maxRequestsPerSocket; + // The Keep-Alive line is invariant per server configuration, so + // memoize the last rendered (timeout, max) pair instead of + // re-building the string for every response. + if (timeoutSeconds !== keepAliveLine.timeout || + maxRequestsPerSocket !== keepAliveLine.max) { + keepAliveLine.timeout = timeoutSeconds; + keepAliveLine.max = maxRequestsPerSocket; + keepAliveLine.value = maxRequestsPerSocket > 0 ? + `Keep-Alive: timeout=${timeoutSeconds}, max=${maxRequestsPerSocket}\r\n` : + `Keep-Alive: timeout=${timeoutSeconds}\r\n`; } - header += `Keep-Alive: timeout=${timeoutSeconds}${max}\r\n`; + header += keepAliveLine.value; } } else { this._last = true; @@ -930,6 +937,10 @@ ObjectDefineProperty(OutgoingMessage.prototype, 'writableNeedDrain', { }); const crlf_buf = Buffer.from('\r\n'); + +// Memoized `Keep-Alive: timeout=N[, max=M]\r\n` line; servers use one +// configuration, so this is effectively computed once per process. +const keepAliveLine = { timeout: -1, max: -1, value: '' }; OutgoingMessage.prototype.write = function write(chunk, encoding, callback) { if (typeof encoding === 'function') { callback = encoding; @@ -1140,6 +1151,17 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { encoding = null; } + // When the final body chunk is written with a known length (no chunked + // trailer to send), the finish notification can ride the body write's + // completion instead of issuing a second, empty socket.write whose only + // purpose is to carry the callback: writes complete in order, so the + // observable finish timing is unchanged while a full Writable pass per + // response disappears. Not applicable under strict content-length + // accounting, where the mismatch check must run after the write and + // before finish is scheduled. + let finish; + let finishAttached = false; + if (chunk) { if (this.finished) { onError(this, @@ -1152,7 +1174,20 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { this[kSocket].cork(); } - write_(this, chunk, encoding, null, true); + if (this._header !== null && this._hasBody && !this.chunkedEncoding && + !this.destroyed && !strictContentLength(this)) { + // The header block is already rendered, so the framing decision is + // final: write_ deterministically reaches the single non-chunked + // _send and the callback is guaranteed to ride the body write. + // (Without a rendered header, write_'s _implicitHeader call may + // still switch the message to chunked encoding, which needs the + // trailing 0\r\n\r\n send below.) + finish = onFinish.bind(undefined, this); + write_(this, chunk, encoding, finish, true); + finishAttached = true; + } else { + write_(this, chunk, encoding, null, true); + } } else if (this.finished) { if (typeof callback === 'function') { if (!this.writableFinished) { @@ -1178,14 +1213,16 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { throw new ERR_HTTP_CONTENT_LENGTH_MISMATCH(this[kBytesWritten], this._contentLength); } - const finish = onFinish.bind(undefined, this); + if (!finishAttached) { + finish ??= onFinish.bind(undefined, this); - if (this._hasBody && this.chunkedEncoding) { - this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish); - } else if (!this._headerSent || this.writableLength || chunk) { - this._send('', 'latin1', finish); - } else { - process.nextTick(finish); + if (this._hasBody && this.chunkedEncoding) { + this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish); + } else if (!this._headerSent || this.writableLength || chunk) { + this._send('', 'latin1', finish); + } else { + process.nextTick(finish); + } } if (this[kSocket]) { diff --git a/lib/_http_server.js b/lib/_http_server.js index 0f5865126689d3..360e0034b01ba8 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -110,6 +110,7 @@ const onResponseFinishChannel = dc.channel('http.server.response.finish'); const kServerResponse = Symbol('ServerResponse'); const kServerResponseStatistics = Symbol('ServerResponseStatistics'); +const kHasResponseCloseListener = Symbol('kHasResponseCloseListener'); const kUpgradeStream = Symbol('UpgradeStream'); const kOptimizeEmptyRequests = Symbol('OptimizeEmptyRequestsOption'); @@ -186,6 +187,10 @@ const STATUS_CODES = { 511: 'Network Authentication Required', // RFC 6585 6 }; +// Lazily filled per-status-code cache of `HTTP/1.1 \r\n` +// status lines (bounded by the status codes actually used). +const statusLineCache = { __proto__: null }; + const kOnExecute = HTTPParser.kOnExecute | 0; const kOnTimeout = HTTPParser.kOnTimeout | 0; @@ -298,7 +303,14 @@ ServerResponse.prototype.assignSocket = function assignSocket(socket) { throw new ERR_HTTP_SOCKET_ASSIGNED(); } socket._httpMessage = this; - socket.on('close', onServerResponseClose); + // The handler is a no-op unless a response is attached (see the + // _httpMessage guard above), so it is installed once per socket and + // left in place instead of paying the add/removeListener pair for + // every response on a keep-alive connection. + if (socket[kHasResponseCloseListener] !== true) { + socket[kHasResponseCloseListener] = true; + socket.on('close', onServerResponseClose); + } this.socket = socket; this.emit('socket', socket); this._flush(); @@ -306,7 +318,6 @@ ServerResponse.prototype.assignSocket = function assignSocket(socket) { ServerResponse.prototype.detachSocket = function detachSocket(socket) { assert(socket._httpMessage === this); - socket.removeListener('close', onServerResponseClose); socket._httpMessage = null; this.socket = null; }; @@ -467,10 +478,20 @@ function writeHead(statusCode, reason, obj) { headers = obj; } - if (checkInvalidHeaderChar(this.statusMessage)) + const statusMessage = this.statusMessage; + if (checkInvalidHeaderChar(statusMessage)) throw new ERR_INVALID_CHAR('statusMessage'); - const statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; + // The status line for a default reason phrase is invariant per status + // code: build it once per code instead of allocating the template + // result for every response. + let statusLine; + if (statusMessage === STATUS_CODES[statusCode]) { + statusLine = statusLineCache[statusCode] ??= + `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; + } else { + statusLine = `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; + } if (statusCode === 204 || statusCode === 304 || (statusCode >= 100 && statusCode <= 199)) { @@ -810,6 +831,10 @@ function connectionListenerInternal(server, socket) { outgoingData: 0, requestsCount: 0, keepAliveTimeoutSet: false, + // Per-connection caches for per-response invariants (see + // parserOnIncoming): initialized here for shape stability. + responseOptions: null, + updateOutgoingData: null, }; state.onData = socketOnData.bind(undefined, server, socket, parser, state); @@ -1280,15 +1305,20 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { } } - const res = new server[kServerResponse](req, - { - highWaterMark: socket.writableHighWaterMark, - rejectNonStandardBodyWrites: server.rejectNonStandardBodyWrites, - }); + // The response options and the pending-data callback only depend on + // the connection, not the request: allocate them once per socket and + // reuse for every response on a keep-alive connection. Neither object + // is retained by the response (the options are only read in the + // OutgoingMessage constructor). + state.responseOptions ??= { + highWaterMark: socket.writableHighWaterMark, + rejectNonStandardBodyWrites: server.rejectNonStandardBodyWrites, + }; + const res = new server[kServerResponse](req, state.responseOptions); res._keepAliveTimeout = server.keepAliveTimeout; res._maxRequestsPerSocket = server.maxRequestsPerSocket; - res._onPendingData = updateOutgoingData.bind(undefined, - socket, state); + res._onPendingData = state.updateOutgoingData ??= + updateOutgoingData.bind(undefined, socket, state); res.shouldKeepAlive = keepAlive; res[kUniqueHeaders] = server[kUniqueHeaders];