Skip to content

Blob.prototype.stream() leaks the source buffer on v26 #63574

@m4n3z40

Description

@m4n3z40

Version

v26.1.0

Platform

Darwin 25.3.0 arm64

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:

Node v26.1.0:  201.0 MiB

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-bugIssues with confirmed bugs.quicIssues and PRs related to the QUIC implementation / HTTP/3.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions