node:stream on workerd drops a 'readable' event under a reentrancy-guarded reader with manual backpressure
A node:stream reader pattern that is robust and deterministic on Node.js deterministically stalls on workerd. When an object-mode Transform stream pushes a second object after its consumer releases manual backpressure, the 'readable' event for that second object is missed.
This occurs because the event fires while a reentrancy guard (reading === true) is still held in the consumer's microtask queue. The object sits in the buffer unread, causing the stream pipeline to hang.
Minimal reproduction
// Node: PASS ("1 OK ID completed" is delivered)
// workerd (vitest): FAIL (only "* ID NIL" delivered)
const { Transform } = require('node:stream');
const t = new Transform({
readableObjectMode: true,
writableObjectMode: false,
transform(chunk, enc, cb) {
const lines = chunk.toString('latin1').split('\r\n').filter(Boolean);
(async () => {
for (const line of lines) {
// push one object, then WAIT for the consumer's next() (backpressure)
await new Promise((resolve) => this.push({ line, next: resolve }));
}
cb();
})();
},
});
const received = [];
let reading = false; // reentrancy guard (as in imapflow's socketReadable)
const reader = async () => {
let d;
while ((d = t.read()) !== null) {
received.push(d.line);
await new Promise((r) => setImmediate(r)); // async gap (like `await handleResponse`)
d.next(); // release backpressure
}
};
t.on('readable', () => {
if (!reading) {
reading = true;
reader()
.catch(() => {})
.finally(() => { reading = false; });
}
});
t.write(Buffer.from('* ID NIL\r\n1 OK ID completed\r\n'));
t.end();
setTimeout(() => {
console.log(received);
console.log(received.length === 2 ? 'PASS' : 'FAIL');
}, 500);
Expected vs actual
| Runtime |
Result |
| Node.js (v24.13.0) |
["* ID NIL","1 OK ID completed"] β PASS (deterministic, repeated runs) |
workerd (miniflare, nodejs_compat_v2) |
["* ID NIL"] β FAIL (deterministic, repeated runs) |
The second pushed object is never surfaced; the consumer's reader() is never re-invoked for it.
What I verified
- The exact same code passes on Node.js and fails on
workerd across many runs. It is a deterministic timing issue, not a flake.
- The failure depends entirely on the
.catch(...).finally(...) promise-chain shape. Without the extra .catch hop, the guard clears in time, and the code passes on workerd too.
- In Node.js,
'readable' emission is deferred to process.nextTick (after all microtasks), but workerd's polyfill appears to emit it earlier, creating a microtask-ordering race condition that Node.js avoids.
Environment
- wrangler
4.125.0, @cloudflare/vitest-plugin 1.0.0, nodejs_compat + nodejs_compat_v2, compat date 2026-08-01
- Node v24.13.0 (reference)
node:streamon workerd drops a'readable'event under a reentrancy-guarded reader with manual backpressureA
node:streamreader pattern that is robust and deterministic on Node.js deterministically stalls onworkerd. When an object-modeTransformstream pushes a second object after its consumer releases manual backpressure, the'readable'event for that second object is missed.This occurs because the event fires while a reentrancy guard (
reading === true) is still held in the consumer's microtask queue. The object sits in the buffer unread, causing the stream pipeline to hang.Minimal reproduction
Expected vs actual
["* ID NIL","1 OK ID completed"]β PASS (deterministic, repeated runs)nodejs_compat_v2)["* ID NIL"]β FAIL (deterministic, repeated runs)The second pushed object is never surfaced; the consumer's
reader()is never re-invoked for it.What I verified
workerdacross many runs. It is a deterministic timing issue, not a flake..catch(...).finally(...)promise-chain shape. Without the extra.catchhop, the guard clears in time, and the code passes onworkerdtoo.'readable'emission is deferred toprocess.nextTick(after all microtasks), butworkerd's polyfill appears to emit it earlier, creating a microtask-ordering race condition that Node.js avoids.Environment
4.125.0,@cloudflare/vitest-plugin1.0.0,nodejs_compat+nodejs_compat_v2, compat date2026-08-01