Skip to content

http: ClientRequest highWaterMark option not propagated to OutgoingMessage #64645

Description

@trivenay

What is the bug?

http.request({ highWaterMark: N }) passes N to the TCP socket but does not set it on the OutgoingMessage's internal kHighWaterMark. This causes two problems:

  1. The user's highWaterMark is silently ignored for writes before the socket connects (all Node versions). write() returns the wrong boolean because _writeRaw Path B checks outputSize < this[kHighWaterMark] which remains at the default 64KB.

  2. Deadlock on Node >= 24.16.0 (post #62936): Because write() incorrectly returns false, kNeedDrain is set. The stricter drain semantics from http: emit 'drain' on OutgoingMessage only after buffers drain #62936 require the socket to emit drain — but the socket was never backpressured (the data is below the socket's HWM). Result: drain never fires, the function hangs forever.

Minimal reproduction

# Terminal 1 — start a simple server
node -e "require('http').createServer((req, res) => { req.resume(); req.on('end', () => res.end('ok')); }).listen(9001)"
// Terminal 2 — repro.js
const http = require('http');

const req = http.request({
  hostname: '127.0.0.1',
  port: 9001,
  method: 'POST',
  highWaterMark: 100_000,  // 100KB — should allow 64KB writes without backpressure
});

const ok = req.write(Buffer.alloc(64 * 1024));
console.log(`write() returned ${ok}, expected true (64KB < 100KB highWaterMark)`);

if (!ok) {
  setTimeout(() => { console.error('DEADLOCK: drain never fired'); process.exit(1); }, 3000);
  req.on('drain', () => { console.log('drain fired'); req.end(); });
} else {
  req.end();
  req.on('response', (res) => { res.resume(); res.on('end', () => process.exit(0)); });
}

Results

Node version write() returns Drain fires? Outcome
v22.22.2 false (wrong, expected true) Yes (old eager drain) HWM ignored but no hang
v26.5.0 false (wrong, expected true) Never DEADLOCK

Root cause

OutgoingMessage._writeRaw() has two mutually exclusive paths:

  • Path A (socket connected): conn.write(data) — backpressure from socket's writableHighWaterMark (correct)
  • Path B (socket not yet connected): outputSize < this[kHighWaterMark] — backpressure from OM's own property (incorrect, stuck at default)

The OutgoingMessage constructor already accepts options.highWaterMark:

// lib/_http_outgoing.js
this[kHighWaterMark] = options?.highWaterMark ?? getDefaultHighWaterMark();

But ClientRequest in lib/_http_client.js never passes the user's highWaterMark to the OutgoingMessage super constructor — it only passes it to createConnection() for the socket.

Expected behavior

write() should return true when the written data is below the user-specified highWaterMark, regardless of whether the socket has connected yet.

Proposed fix

Propagate the user's highWaterMark option to OutgoingMessage in the ClientRequest constructor so both write paths respect the same threshold.

Refs: #62936, #39092, #32781

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions