Memory leak: 6GB of weakly-held ArrayBuffers not freed (native backing stores)
Description
Claude Code leaks ~5 MB/s of native memory through ArrayBuffer backing stores that are never freed. A session that ran for only 106 seconds accumulated 6.7 GB of memory, almost entirely in externally-backed ArrayBuffers.
Environment
- Claude Code: 2.1.72
- Node.js: v24.3.0
- Platform: macOS (Darwin 25.1.0)
- ~60 MCP servers configured
Evidence from heap snapshot analysis
Diagnostics summary (from built-in heap dump)
{
"uptimeSeconds": 106,
"memoryUsage": {
"heapUsed": 6746204797,
"heapTotal": 57854976,
"external": 6699911341,
"arrayBuffers": 6250945148,
"rss": 534560768
},
"memoryGrowthRate": {
"bytesPerSecond": 5024999,
"mbPerHour": 17251
}
}
Key observation: V8 heap is only 58 MB, but external memory is 6.7 GB. Almost all of it (6.25 GB) is in arrayBuffers.
Programmatic heap snapshot analysis
Parsed the .heapsnapshot file with a Node.js script. Findings:
1. 11,479 ArrayBuffers at uniform ~512KB size (5,741 MB total)
| Size bucket | Count | Total MB |
|---|---|---|
| 512KB - 1MB | 11,479 | 5,741 |
| 100KB - 512KB | 799 | 200 |
| >10MB | 1 | 16 (WebAssembly.Memory, expected) |
2. All large ArrayBuffers are only weakly reachable from the GC root
=== OBJECTS REFERENCING ARRAYBUFFERS (by type::name) ===
5940.94 MB | 12278 refs | synthetic::(root) [via weak reference]
16.00 MB | 1 refs | code::JSLexicalEnvironment [WASM, expected]
No JS object holds a strong reference to these buffers. They are retained only by synthetic::(root) via weak edges. Despite being weakly held in V8, the native backing stores are never freed because V8/JSC does not control external memory deallocation.
3. Uniform ~512KB size suggests stdio transport reads
The consistent buffer size points to stdout.on('data') chunks from MCP server stdio transports. Each chunk allocates a native-backed ArrayBuffer. With many MCP servers, these accumulate rapidly.
4. Separate leak: 181 MB unconsumed HTTP Response
A single Response object (181 MB) is retained by a pending Promise chain:
Response (181 MB)
<- IncomingMessage via "FetchAPI"
<- JSLexicalEnvironment via "response"
<- ... closures via "stream"/"src"
<- Generator
<- PromiseReaction
<- Promise (pending)
<- Object via "end" (onend callback never fired)
This is an HTTP response whose stream was never fully consumed, so the body stays in memory indefinitely.
Uint8Array retention pattern
Uint8Arrays >= 1KB: 58
46 refs | JSLexicalEnvironment [chunk]
46 refs | Readable [remainingChunk]
4 refs | NativeReadableStreamSource [data]
46 Readable streams hold remainingChunk references to Uint8Array views over the leaked ArrayBuffers, confirming the stream/transport origin.
Reproduction
- Configure many MCP servers (stdio transport)
- Start a Claude Code session
- Monitor
process.memoryUsage().arrayBuffers- it grows at ~5 MB/s - After ~2 minutes, memory exceeds 6 GB
Likely root cause
The MCP stdio transport reads chunks from child process stdout. Each read creates an externally-backed ArrayBuffer. After the chunk is processed, the JS reference is dropped, but:
- The ArrayBuffer's native backing store is not freed because the allocator (Node.js Buffer pool or libuv) retains ownership
- V8's GC sees the wrapper as weakly reachable but cannot trigger deallocation of external memory
- On Node v24 (with JavaScriptCore on macOS), this may be exacerbated by different GC heuristics for external memory pressure
Related issues
- #19317 - Memory leaks cause system crashes
- #11155, #18859 - Multiple Claude processes leak memory
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗