ArrayBuffer and Blob Memory Outside the JS Heap
Binary data is the one category of memory where every standard diagnostic tool lies to you by design. This page belongs to typed arrays, buffers and external memory within JavaScript Memory Fundamentals & Runtime Mechanics, and covers measuring and releasing the two most common carriers of it.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Process killed with a 40 MB heap | Growth is entirely in external |
Chart external and arrayBuffers |
| Blob memory never returns | Object URL never revoked | Call URL.revokeObjectURL for every created URL |
| A 64-byte view retains 8 KB | subarray over a pooled Node buffer |
Copy with Buffer.from when retaining long-term |
| Memory falls only at some unrelated moment | Wrapper too small to trigger a collection | Drop references promptly; pool instead of allocating |
| Peak memory scales with request rate | One buffer allocated per operation | Reuse a fixed pool sized to concurrency |
Root Cause: The Wrapper Is Not the Data
An ArrayBuffer is two allocations. The JavaScript object — the thing with a byteLength property that a heap snapshot records — is around a hundred bytes. The backing store, where the actual bytes live, is allocated outside the V8 heap through the engine’s array-buffer allocator. Collecting the wrapper releases the backing store; nothing else does.
That indirection creates the pressure problem. V8 decides when to collect based on heap pressure, and a hundred-byte wrapper contributes a hundred bytes of pressure whether it points at 1 KB or 100 MB. Allocate fifty 20 MB buffers and drop them, and the heap has grown by five kilobytes — no reason to collect — while the process holds a gigabyte the kernel is counting. V8 applies an external-memory heuristic to nudge collections, but an allocation loop outruns it easily.
Blobs add a second retention path. URL.createObjectURL(blob) registers the blob in a store owned by the document and returns a string. That store holds a strong reference: dropping your blob variable frees nothing, because the URL entry still points at it. Only revokeObjectURL — or discarding the document — releases it.
Node’s Buffer adds a third wrinkle. Allocations under 4 KB are carved from a shared 8 KB pool, so a 64-byte subarray retained long-term keeps the entire slab alive. At scale that turns a small cache of parsed headers into tens of megabytes of mostly-empty pooled slabs.
Step-by-Step Fix
- Measure the right number. Command (Node):
process.memoryUsage().arrayBuffers. Command (browser):await performance.measureUserAgentSpecificMemory()with cross-origin isolation enabled. Expected: a figure that matches the growth you are chasing, unlike the heap. - Find the owners. DevTools path: Memory → Heap snapshot → Summary → filter
ArrayBuffer→ Retainers. Expected: a small number of owning structures — a cache, an array of frames, a queue. - Audit every
createObjectURL. Command:grep -rn 'createObjectURL' src | wc -lagainst the count ofrevokeObjectURL. Expected: the two counts should match; they almost never do on the first pass. - Copy small retained views. Replace
buf.subarray(a, b)withBuffer.from(buf.subarray(a, b))wherever the result outlives the parse. Verification:arrayBuffersstops tracking the number of parsed messages. - Pool the hot path. Verification: peak
externalbecomespoolSize × bufferBytesand stops scaling with throughput.
Command & Code Reference
Measuring what a workload actually holds outside the heap.
// node --expose-gc external.js
async function externalDelta(run) {
global.gc();
const a = process.memoryUsage();
const kept = await run(); // whatever the workload retains
global.gc();
const b = process.memoryUsage();
console.log({
heapMb: ((b.heapUsed - a.heapUsed) / 1048576).toFixed(1),
externalMb: ((b.external - a.external) / 1048576).toFixed(1),
arrayBuffersMb: ((b.arrayBuffers - a.arrayBuffers) / 1048576).toFixed(1),
});
return kept; // returned so it is not collected early
}
The blob lifecycle, done so that nothing depends on remembering a second call at the right moment.
// Pairing creation with revocation in one helper removes the whole class of bug.
function withObjectUrl(blob, use) {
const url = URL.createObjectURL(blob);
try {
return use(url);
} finally {
// Revoked even if `use` throws; the blob's bytes are released here.
URL.revokeObjectURL(url);
}
}
// For a URL that must outlive the call — an <img> src, for example — revoke
// on load, which is the earliest safe moment.
function showImage(blob, img) {
const url = URL.createObjectURL(blob);
img.addEventListener('load', () => URL.revokeObjectURL(url), { once: true });
img.src = url;
}
Detaching a small view from a pooled Node buffer, and bounding a hot path with a pool.
const { Buffer } = require('node:buffer');
function parseHeader(chunk) {
// subarray shares the backing store — fine transiently, a leak if retained.
const view = chunk.subarray(0, 64);
// Buffer.from copies into a right-sized allocation, releasing the 8 KB slab.
return Buffer.from(view);
}
function createPool({ size = 8, bytes = 1 << 20 } = {}) {
const free = Array.from({ length: size }, () => Buffer.allocUnsafe(bytes));
let outstanding = 0;
return {
acquire() {
outstanding++;
// A miss means the pool is undersized — log it rather than hiding it.
return free.pop() ?? Buffer.allocUnsafe(bytes);
},
release(buf) {
outstanding--;
if (free.length < size) free.push(buf);
},
get inFlight() { return outstanding; },
};
}
Verification & Regression Prevention
Verify against external, never against the heap. A fix that returns heapUsed to baseline while external stays high has released the wrappers and nothing else — usually because a blob URL or a native handle is still holding the bytes.
For prevention, wrap creation and release in paired helpers as shown above, so the two calls cannot drift apart during a refactor, and add a lint rule or a grep-based pre-commit check asserting that createObjectURL and revokeObjectURL appear the same number of times. In Node, export arrayBuffers as a gauge next to heapUsed, as covered in production memory monitoring; a series that tracks request volume is a pool that is not being returned to.
FAQ
Why does the heap snapshot show my ArrayBuffer as tiny?
The snapshot records the JavaScript wrapper, which is around a hundred bytes. The backing store is allocated outside the V8 heap by the array-buffer allocator, so it never appears in the snapshot at all. Use process.memoryUsage().arrayBuffers, or the browser’s memory measurement API, to see the real figure.
Do I have to revoke an object URL if I drop the blob reference?
Yes. createObjectURL registers the blob in a URL store owned by the document, and that store holds a strong reference regardless of what your code does with the original variable. Until revokeObjectURL runs, or the document is discarded, the blob’s bytes stay resident.
Is slicing a Buffer in Node a copy?
subarray and the deprecated slice return a view over the same backing store, so no bytes are copied and the parent stays alive. Buffer.from(view) copies into a fresh allocation. For small views retained long-term, copy — otherwise a 64-byte header can keep an 8 KB pooled slab alive.
Related
- Typed Arrays, Buffers and External Memory — the full picture of memory outside the heap
- Canvas and ImageBitmap Memory in Long-Running Apps — the rendering-engine side of the same problem
- Transferring ArrayBuffers vs Copying Between Workers — moving these buffers between threads without duplicating them