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.
Confirming this independently, with a few additional data points that may help prioritise the fix in #66782.
Reproduces without an artificially slow reader. A plain http.request client in the same process (no throttling) hits it too. 4000 rows, ~270 KB total, written with either Readable.from(strings).pipe(res) or a manual write()/'drain' loop. Any one non-ASCII string chunk (é, —, 😀) is enough. ASCII-only data of the same size completes. It stalls after about 65 KB; on v24.22.1 the stuck state is res.writableLength === 0, res.socket.writableLength === 0, not corked, and 'drain' never fires.
Affected versions (one run each, repeated on 24.22.x and 26.12.x):
Node
non-ASCII body
22.23.4, 24.21.0, 25.9.0, 26.7.0, 26.8.2
completes
24.22.0, 24.22.1
stalls
26.9.0, 26.10.0, 26.11.1, 26.12.0, 26.12.1
stalls
The October 21 security releases (24.22.1 and 26.12.1) still have the bug. That leaves 24.x/26.x users choosing between the security fixes and working chunked responses; the only workaround on 24.x is to stay on 24.21.0, which is not patched. 22.23.4 is not affected.
Real-world impact: a production endpoint that streams JSON with res.write/pipe stopped completing for responses over 64 KiB that contain —, – or ’. Replacing those characters with ? made it complete.
I opened #66924 before finding this issue and am closing it as a duplicate. Happy to test #66782 against my repro if useful.
Small follow-up that may help narrow the fix: the problem only occurs for string chunks. Writing the same data as Buffers (res.write(Buffer.from(piece))) completes on v24.22.1 and v26.12.1 with the same repro, which fits the byte vs UTF-16 code unit accounting described above (Buffers are counted in bytes by the socket). It also explains why one JSON streaming library that emits Buffer chunks was unaffected while a newer major that emits strings stalled. Converting string chunks to Buffers is a workable interim workaround on affected versions.
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
imrihe-ledge commentedon Oct 22, 2026
Confirming this independently, with a few additional data points that may help prioritise the fix in #66782.
Reproduces without an artificially slow reader. A plain
http.requestclient in the same process (no throttling) hits it too. 4000 rows, ~270 KB total, written with eitherReadable.from(strings).pipe(res)or a manualwrite()/'drain'loop. Any one non-ASCII string chunk (é,—,😀) is enough. ASCII-only data of the same size completes. It stalls after about 65 KB; on v24.22.1 the stuck state isres.writableLength === 0,res.socket.writableLength === 0, not corked, and'drain'never fires.Affected versions (one run each, repeated on 24.22.x and 26.12.x):
The October 21 security releases (24.22.1 and 26.12.1) still have the bug. That leaves 24.x/26.x users choosing between the security fixes and working chunked responses; the only workaround on 24.x is to stay on 24.21.0, which is not patched. 22.23.4 is not affected.
Real-world impact: a production endpoint that streams JSON with
res.write/pipestopped completing for responses over 64 KiB that contain—,–or’. Replacing those characters with?made it complete.I opened #66924 before finding this issue and am closing it as a duplicate. Happy to test #66782 against my repro if useful.
imrihe-ledge commentedon Oct 22, 2026
Small follow-up that may help narrow the fix: the problem only occurs for string chunks. Writing the same data as
Buffers (res.write(Buffer.from(piece))) completes on v24.22.1 and v26.12.1 with the same repro, which fits the byte vs UTF-16 code unit accounting described above (Buffers are counted in bytes by the socket). It also explains why one JSON streaming library that emitsBufferchunks was unaffected while a newer major that emits strings stalled. Converting string chunks to Buffers is a workable interim workaround on affected versions.Bun, erlang certifi and CPython fixes; four test hosts; engines range…