Request.body.cancel() hangs after clone() when both streams are unused
Bug Description
When cancel() is called on the body of a Request object that has been cloned via .clone(), but neither the original nor the cloned stream has been consumed, the promise returned by cancel() hangs indefinitely.
Specifically, if you create a Request object with the fetch API, clone it using the .clone() method, and then call await request.body.cancel() while both the original and the cloned streams are unconsumed (bodyUsed is false), the promise never settles.
Reproducible By
The bug can be reliably reproduced with the following minimal code.
(async function () {
console.log(`Running on Node.js ${process.version}`);
let r = new Request('https://example.org', {
method: 'POST',
body: 'body',
});
// Cloning the request creates two tee'd streams from the body.
let clone = r.clone();
// This bug only occurs when neither the original nor the cloned stream has been consumed.
if (!r.bodyUsed) {
console.log('Original request body is not used. Calling cancel()...');
const keepAlive = setInterval(() => {
console.log('Still waiting...');
}, 1000);
try {
await r.body?.cancel();
console.log('Cancel promise resolved successfully.');
clearInterval(keepAlive);
} catch (error) {
console.error('Cancel promise rejected:', error);
clearInterval(keepAlive);
}
}
})();
Run the file from your terminal using the command node test.js.
Expected Behavior
The script should complete its execution immediately and exit gracefully. The await r.body?.cancel() promise should resolve successfully, and the console output should be as follows:
Running on Node.js v24.7.0
Original request body is not used. Calling cancel()...
Cancel promise resolved successfully.
Logs & Screenshots
The script prints the second log message and then hangs indefinitely. The process does not exit because the await statement never completes.
Running on Node.js v24.7.0
Original request body is not used. Calling cancel()...
Still waiting...
Still waiting...
Still waiting...
Still waiting...
Environment
Node: v24.7.0 OS: MacOS
Additional context
This bug appears to stem from a deadlock within Node.js's implementation of the WHATWG Streams API, specifically concerning streams created by Request.clone().
The core issue is triggered when cancel() is called on one branch of a cloned request body while both the original and the clone remain "undisturbed" (i.e., bodyUsed is false for both). Internally, Request.clone() uses ReadableStream.tee() to split the stream. My hypothesis is that Node.js's cancellation logic incorrectly causes the canceled branch to wait for a state change from the other unused branch. Since the other branch is never acted upon, this wait state becomes a permanent deadlock.
This behavior is likely a Node.js-specific implementation flaw, as the identical code runs correctly and terminates as expected in other standards-compliant runtimes like Deno.
Question
According to the Streams Standard, cancel() should resolve immediately even if the other tee branch is unused. Could you confirm whether this behavior is spec-compliant, or if this is indeed an implementation bug in undici/Node.js streams?
cc @KhafraDev
(async () => {
const rs = new ReadableStream({
start (c) {
c.enqueue(new Uint8Array(1000))
c.close()
}
})
const [out1, out2] = rs.tee()
await out1.cancel()
})()
What Request.clone is doing (in undici) is more or less this. I have not been able to figure out why this causes issues in node/why this does not cause issues in other environments.
Thanks, @KhafraDev. The code you shared was very helpful and allowed me to dig deeper into this issue.
After further investigation, it appears this isn't a bug in Node.js but is actually the expected behavior resulting from Node.js strictly following the WHATWG Streams specification.
The spec for ReadableStream.tee(), which is used internally by Request.clone(), states that to fully close the stream, both of the teed branches must be canceled. I believe my original code was hanging because it only attempted to cancel one of the streams, leaving the other one active and preventing the process from exiting.
In fact, when I modified the code to cancel both streams using Promise.all, I confirmed that the issue is resolved and the process now terminates correctly in Node.js.
// This now terminates correctly when both streams are canceled.
(async () => {
const rs = new ReadableStream({
start(c) {
c.enqueue(new Uint8Array(1000));
c.close();
}
});
const [out1, out2] = rs.tee();
console.log('Canceling both streams...');
await Promise.all([
out1.cancel(),
out2.cancel()
]);
console.log('Both streams canceled successfully.');
})();
So, it seems the key insight here is that Node.js correctly follows the spec, which requires both teed stream branches to be canceled. This still leaves me wondering, however, why other runtimes like Deno behave differently and exit without issue when only one branch is canceled.