You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
http: 'drain' is never emitted for chunked responses with multi-byte strings (v24.22.0 regression) #66781
v24.22.0 (also main / v27.0.0-pre; v24.21.0 is not affected)
Platform
Darwin arm64 (macOS); also linux x64 in Docker (node:24-alpine)
Subsystem
http
What steps will reproduce the bug?
Run the following single-file script with node repro.js. It starts an HTTP server that writes 30k multi-byte string chunks to a chunked http.ServerResponse, awaiting 'drain' whenever write() returns false. A forked child process reads the response slowly.
'use strict';consthttp=require('node:http');const{ once }=require('node:events');const{ fork }=require('node:child_process');if(process.argv[2]==='client'){// Slow consumer in a separate process.http.get(`http://127.0.0.1:${process.argv[3]}/`,(res)=>{letbytes=0;res.on('data',(chunk)=>{bytes+=chunk.length;constt=performance.now();while(performance.now()-t<2);// simulate per-chunk work});res.on('end',()=>process.send({done: true, bytes }));});return;}constROWS=30000;// Rows of mixed ASCII + 2-byte UTF-8 characters, ~200-600 bytes each.constrows=Array.from({length: ROWS},(_,i)=>'a'.repeat(50+(i*37)%200)+'ع'.repeat(50+(i*53)%150)+'\n');constserver=http.createServer(async(req,res)=>{for(leti=0;i<ROWS;i++){if(!res.write(rows[i])){consttimer=setTimeout(()=>{console.log(`${process.version}: STALLED at row ${i}`,{writableNeedDrain: res.writableNeedDrain,writableLength: res.writableLength,socketWritableLength: res.socket.writableLength,socketWritableNeedDrain: res.socket.writableNeedDrain,});process.exit(1);},3000);awaitonce(res,'drain');clearTimeout(timer);}}res.end();});server.listen(0,()=>{constchild=fork(__filename,['client',server.address().port]);child.on('message',({ bytes })=>{console.log(`${process.version}: OK, client received ${bytes} bytes`);child.kill();process.exit(0);});});
How often does it reproduce? Is there a required condition?
Every time in our environment (it stalls at the same row on every run).
Required conditions:
a chunked response (no Content-Length)
multi-byte string chunks: the same script with ASCII-only rows of identical byte length does not stall
a reader slow enough to apply backpressure (a fast reader such as curl does not trigger it)
What is the expected behavior? Why is that the expected behavior?
After res.write() returns false, 'drain' is emitted once the buffered data has been flushed, as documented for writable streams. v24.21.0 completes:
v24.21.0: OK, client received 11985000 bytes
What do you see instead?
'drain' is never emitted, even though nothing is buffered anymore:
The writer waits forever. In production the client (undici) eventually aborts with UND_ERR_BODY_TIMEOUT after 300 s.
Additional information
This started with v24.22.0, which includes http: coalesce chunked writes during auto-corking #64987 (http: coalesce chunked writes during auto-corking). With that change, write() decides its return value from the message-level buffer (msg.writableLength), where kChunkedLength is measured in bytes (Buffer.byteLength). Once that reaches the high-water mark it returns false and sets kNeedDrain.
The coalesced chunks are then flushed in the next-tick uncork() via socket.write() with string data, and net.Socket accounts string chunks in UTF-16 code units, not bytes:
socket.cork();socket.write('ع'.repeat(1000));// 2000 bytes on the wiresocket.writableLength;// 1000 (same on v24.21.0 and v24.22.0)
So with multi-byte text the socket believes it is below its high-water mark, socket.write() returns true, the socket never sets needDrain and never emits 'drain'. The other emit path (uncork() checking writableLength === 0 right after the flush) is missed because bytes are still pending at that instant. kNeedDrain stays true with nothing buffered.
Before http: coalesce chunked writes during auto-corking #64987 the return value came from the socket itself, so the two accountings could not disagree. With ASCII-only data bytes equal code units, which is why only multi-byte bodies are affected.
http: count buffered outgoing data in bytes #66039 (http: count buffered outgoing data in bytes) looks related but does not cover this: it changes the buffered branch of _writeRaw() (outputData / outputSize), and instrumenting this repro shows that branch is never taken (every write goes straight to the socket).
I have a fix with a test that re-checks the drain condition once the flushed data has been written, and will open a PR referencing this issue.
Activity
par333k commentedon Oct 16, 2026
In environments that use CJK characters, such issues frequently arise, regardless of the library being used. I look forward to a quick resolution.
http: emit 'drain' after flushing coalesced chunks