Version
v26.1.0
Platform
Also reproduces on Linux arm64 (v26.1.0).
Subsystem
buffer
What steps will reproduce the bug?
// node --expose-gc repro.mjs
const mb = () => (process.memoryUsage().arrayBuffers / 1024 / 1024).toFixed(1);
const buf = Buffer.alloc(1024 * 1024); // 1 MiB
for (let i = 0; i < 200; i++) {
const stream = new Blob([buf]).stream();
await stream.cancel();
}
global.gc();
await new Promise(r => setTimeout(r, 100));
global.gc();
console.log('arrayBuffers:', mb(), 'MiB');
How often does it reproduce? Is there a required condition?
Every time, as long as Blob.prototype.stream() is called. The Blob on its own is fine: new Blob([buf]) and blob.arrayBuffer() don't retain anything. Only .stream() does, and it doesn't matter whether the stream is cancelled immediately or fully drained.
What is the expected behavior? Why is that the expected behavior?
After the loop and a couple of GC passes arrayBuffers should be back near baseline (~1 MiB, the single live buf), because none of the blobs or streams are reachable anymore. That is what v22, v24 and v25 do:
Node v22.17.0: 1.0 MiB
Node v24.15.0: 1.0 MiB
Node v25.1.0: 1.0 MiB
What do you see instead?
On v26 every iteration keeps its 1 MiB input buffer alive permanently:
A heap snapshot shows each backing store retained by a Blob that is pinned in eternal handles:
Node / BackingStore
<- internal:store Node / InMemoryEntry
<- element std::vector<std::unique_ptr<Entry>>
<- internal:entries Node / DataQueue
<- internal:data_queue_ Node / Blob
<- internal:javascript_to_native Blob
<- (Global handles / Eternal handles)
<- GC root
Additional information
I ran into this chasing ~6 GB RSS in a service that uses isomorphic-git, which deflates git objects with new Blob([buffer]).stream().pipeThrough(new CompressionStream('deflate')). The leak never shows up in the V8 heap, only in arrayBuffers / RSS, which made it hard to find. It bisects cleanly to the v25 -> v26 boundary.
Version
v26.1.0
Platform
Also reproduces on Linux arm64 (v26.1.0).
Subsystem
buffer
What steps will reproduce the bug?
How often does it reproduce? Is there a required condition?
Every time, as long as
Blob.prototype.stream()is called. The Blob on its own is fine:new Blob([buf])andblob.arrayBuffer()don't retain anything. Only.stream()does, and it doesn't matter whether the stream is cancelled immediately or fully drained.What is the expected behavior? Why is that the expected behavior?
After the loop and a couple of GC passes
arrayBuffersshould be back near baseline (~1 MiB, the single livebuf), because none of the blobs or streams are reachable anymore. That is what v22, v24 and v25 do:What do you see instead?
On v26 every iteration keeps its 1 MiB input buffer alive permanently:
A heap snapshot shows each backing store retained by a
Blobthat is pinned in eternal handles:Additional information
I ran into this chasing ~6 GB RSS in a service that uses isomorphic-git, which deflates git objects with
new Blob([buffer]).stream().pipeThrough(new CompressionStream('deflate')). The leak never shows up in the V8 heap, only inarrayBuffers/ RSS, which made it hard to find. It bisects cleanly to the v25 -> v26 boundary.