Skip to content

http: count buffered outgoing data in bytes - #66039

Open
QuantumBreakz wants to merge 2 commits into
nodejs:mainfrom
QuantumBreakz:fix-57985-outgoing-bytelength
Open

QuantumBreakz wants to merge 2 commits into
nodejs:mainfrom
QuantumBreakz:fix-57985-outgoing-bytelength

Conversation

@QuantumBreakz

@QuantumBreakz QuantumBreakz commented Sep 15, 2026

Copy link
Copy Markdown

OutgoingMessage#outputSize, and the per-connection counter updated through
_onPendingData(), exist to decide when the socket should be paused to apply
backpressure. The comment on outputSize says as much:

// `outputSize` is an approximate measure of how much data is queued on this
// response. `_onPendingData` will be invoked to update similar global
// per-connection counter. That counter will be used to pause/unpause the
// TCP socket and HTTP Parser and thus handle the backpressure.

When a write is buffered rather than passed straight to the socket, both were
increased by data.length. For a string that is a count of UTF-16 code units,
not the number of bytes that will go on the wire, so multi-byte bodies were
under-accounted — 2x for two byte characters, 3x for most CJK text.

The visible consequence is that write() keeps reporting there is room when
the buffer is already past the high water mark, and updateOutgoingData() in
lib/_http_server.js pauses the socket later than it should:

const msg = new OutgoingMessage({ highWaterMark: 100 });
msg._implicitHeader = () => {};
msg.write('é'.repeat(50));   // exactly 100 bytes
// before: outputSize === 50,  write() === true
// after:  outputSize === 100, write() === false

The size argument

_writeRaw() has accepted the byte length its callers already computed, as
size, since #46601 — but never read it, which is what #57985 reports:

len ??= typeof chunk === 'string' ? Buffer.byteLength(chunk, encoding) : chunk.byteLength;
...
msg._send(chunk, encoding, null, len);

This uses that value and falls back to measuring the string when it is not
supplied, since the non-chunked path computes len lazily and often leaves it
undefined.

The history suggests the dead parameter was an oversight rather than a
deliberate no-op: #46601 added byteLength to _send() and size to
_writeRaw() while reading neither, and #46605 — which would have used it, as
this.outputSize += size ?? data.byteLength ?? data.length — was opened
against an earlier tree and closed after a review comment about
TypedArray.prototype.length, which addressed its stated justification rather
than the accounting. The plumbing landed; the consumer did not.

The prepended header

One detail worth review. _send() prepends the header to the first string
chunk:

data = this._header + data;

After that, a byteLength measured by the caller describes only the body, so
adding it to outputSize unchanged would drop the header's bytes from the
count entirely. The header's byte length is therefore added to it. Header
values are restricted to \x00-\xff by checkInvalidHeaderChar(), so no
surrogate pair can straddle the join and the two lengths are additive.

The other branch of that if, which queues the header as its own entry, was
already correct — it is written as latin1, where .length is the byte
length — and is untouched.

Verification

Added test/parallel/test-http-outgoing-buffer-bytelength.js. Every
assertion was checked against an unpatched build of this same tree: the
fix-dependent cases each fail with exactly the code-unit value (119 vs 219
for the header case, 206/225 for chunked, 8 vs 4 for hex and base64),
and the ASCII, Buffer and latin1 cases pass unchanged both before and
after, which is what pins down that those paths do not move.

All 768 test-http*/test-https* parallel tests pass.

Performance

The added Buffer.byteLength() call only runs on the buffered path, and only
when a caller did not already supply the length. Instrumenting a keep-alive
server shows 83.3% of _writeRaw() calls go straight to the socket and never
reach the accounting at all.

End to end throughput with a multi-byte body on every response is unchanged
(3 runs each, 16 connections, keep-alive):

body before after
2.8 kB 6169 / 5882 / 6099 req/s 6150 / 6208 / 5840 req/s
70 kB 2635 / 2244 / 3450 req/s 3362 / 3357 / 2973 req/s

A micro-benchmark that forces every write to buffer does show the extra
measurement (~25% on that operation alone), but that is the path that only
runs once the message is already queuing.

Notes

  • Nothing about what is written to the socket changes, only the accounting.
  • write() can now return false where it previously returned true for a
    multi-byte body, which is the point of the fix. I do not think that warrants
    semver-major, but flagging it for reviewers.

AI disclosure

Per the project's AI use policy:
this patch and its test were written with the assistance of an AI coding agent
(Claude Code). What I did to verify the output myself, rather than trusting it:

  • Reproduced the under-counting on a released binary (v22.22.2) before changing
    anything, and confirmed against the source that size is still unused on main.
  • Built main locally and confirmed the new test fails there without the patch
    (100 !== 200) and passes with it, so the test is known to catch the defect
    rather than merely describe the implementation.
  • Checked every expected value by hand against that unpatched build: the
    fix-dependent cases fail with exactly the UTF-16 code-unit value (119 vs
    219 for the header case, 206/225 for chunked, 8 vs 4 for hex and
    base64), while the ASCII, Buffer and latin1 cases pass both before and
    after — which is what pins down that those paths do not move.
  • Ran the full parallel and sequential suites: 5038 pass, 4 fail. Those 4
    fail identically on an unpatched binary built from the same tree, so they are
    pre-existing and specific to my build configuration (no Rust/FFI, OpenSSL CA
    env), not to this change.
  • Measured the performance question instead of asserting it: instrumented the
    buffered-versus-direct ratio, and A/B'd throughput between the two binaries.
  • Confirmed the header-prepend reasoning against the source: checkInvalidHeaderChar()
    restricts header values to \x00-\xff, which is what makes the two byte
    lengths additive.

I can explain and defend both the change and the test during review.

Fixes: #57985
Refs: #46601
Refs: #46605

`OutgoingMessage#outputSize`, and the per-connection counter updated
through `_onPendingData()`, decide when the socket is paused to apply
backpressure, so both are meant to hold a number of bytes. When a write
is buffered instead of being handed straight to the socket, they were
increased by `data.length`, which for a string is a count of UTF-16 code
units rather than its size on the wire.

Multi-byte bodies were therefore under-accounted. A UTF-8 response built
from two byte characters was counted at half its real size, so `write()`
kept reporting that there was room and the socket was paused later than
it should have been.

`_writeRaw()` already received the byte length its callers had computed,
as `size`, but never read it. Use it, and fall back to measuring the
string when it is not supplied. `_send()` prepends the header to the
first string chunk, so add the header's byte length to the value handed
on, otherwise the bytes it contributes are dropped from the count.

Fixes: nodejs#57985
Refs: nodejs#46601
Refs: nodejs#46605
Signed-off-by: Ali Ahmed <ali.lah.aed456@gmail.com>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/http
  • @nodejs/net

@nodejs-github-bot nodejs-github-bot added http Issues and PRs related to the http subsystem. needs-ci PRs that need a full CI run. labels Sep 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Welcome to Node.js, and thank you for your first contribution!

Before review, please take a moment to read:

Please make sure every commit is signed off. For a first pull request, GitHub Actions require collaborator approval and Jenkins CI must be started by a collaborator or triager, so an initial wait is normal.

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

This makes thing slower without solving a practical problem.

@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.23%. Comparing base (11ed325) to head (cbb1789).
⚠️ Report is 11 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #66039   +/-   ##
=======================================
  Coverage   90.23%   90.23%           
=======================================
  Files         789      789           
  Lines      270476   270490   +14     
  Branches    51751    51746    -5     
=======================================
+ Hits       244052   244074   +22     
+ Misses      16907    16896   -11     
- Partials     9517     9520    +3     
Files with missing lines Coverage Δ
lib/_http_outgoing.js 97.92% <100.00%> (+0.02%) ⬆️

... and 31 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.

Single byte encodings have a byte length equal to the string length, so
measuring them with `Buffer.byteLength()` is wasted work. Every write
this module makes internally — the header block, the chunk size lines
and the trailer — is latin1, so they were all paying for a scan that
could only return `data.length`.

Check for those encodings before measuring. This takes the overhead of
the previous commit on a chunked write from ~13% to ~5%, and leaves
utf8 bodies, which do have to be measured, unaffected.

Refs: nodejs#57985
Signed-off-by: Ali Ahmed <ali.lah.aed456@gmail.com>
@QuantumBreakz

QuantumBreakz commented Sep 15, 2026

Copy link
Copy Markdown
Author

@ronag You're right about the cost, and I measured it rather than guessing. A
buffered string write goes from ~22ms to ~36ms per 400k iterations (median of
7 runs), so roughly +30ns per write. Buffer writes are unchanged.

I've pushed a second commit that skips the measurement for single byte
encodings, since every write this module makes internally, the header block,
the chunk size lines, the trailer, is latin1, where the byte length is just
the string length. That takes the overhead on a chunked write from ~13% to
~5%. utf8 bodies still have to be measured; I don't see a way around that.

On the practical side, two things I'd like your read on.

outgoingMessage.writableLength is documented as "The number of buffered
bytes", but it returns UTF-16 code units, so it's wrong for any non-ASCII
body.

More concretely, a queued response overshoots its high water mark, because
while (res.write(chunk)) stops at outputSize < highWaterMark:

payload hwm bytes buffered overshoot
ascii 65536 65536 1.00x
2-byte UTF-8 65536 131072 2.00x
CJK 3-byte 65536 196608 3.00x

So a response serving CJK holds 3x the memory it was configured for.
updateOutgoingData() pausing the socket doesn't bound this, since that stops
new requests rather than writes to an already queued response.

So, directly: is that overshoot acceptable to you? If it is, I'll close this
and won't argue the point, you know this code better than I do. If it isn't,
but paying ~30ns to measure the string isn't either, say which direction you'd
accept and I'll do that instead.

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

Labels

http Issues and PRs related to the http subsystem. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

size is unused in OutgoingMessage.prototype._writeRaw

3 participants