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.

Three ways binary bytes stay resident An ArrayBuffer wrapper of about a hundred bytes holds a backing store of any size. A blob URL entry in the document's store holds its blob independently of any JavaScript variable. A sixty-four byte Buffer view keeps the whole eight kilobyte pooled slab it was carved from alive. Each row is a reference the collector respects and a snapshot does not show ArrayBuffer wrapper ~100 B on the heap backing store · 20 MB, outside the heap freed only when the wrapper is collected document URL store blob: URL entry Blob · 48 MB of decoded video held until revokeObjectURL, whatever your variables do Buffer view · 64 B from subarray() pooled slab · 8 KB 128× the view's size, alive for as long as the view is

Step-by-Step Fix

  1. 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.
  2. 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.
  3. Audit every createObjectURL. Command: grep -rn 'createObjectURL' src | wc -l against the count of revokeObjectURL. Expected: the two counts should match; they almost never do on the first pass.
  4. Copy small retained views. Replace buf.subarray(a, b) with Buffer.from(buf.subarray(a, b)) wherever the result outlives the parse. Verification: arrayBuffers stops tracking the number of parsed messages.
  5. Pool the hot path. Verification: peak external becomes poolSize × bufferBytes and 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; },
  };
}
Per-request allocation versus a fixed pool Under rising concurrency, per-request allocation drives external memory from twenty to nine hundred and sixty megabytes and the process is killed. A pool of eight one-megabyte buffers holds external memory flat at twenty-eight megabytes across the same load. Same upload workload, two allocation strategies 0 500 MB 1 GB concurrency (0 → 120 requests) container limit one buffer per request → 960 MB pool of 8 × 1 MB → flat at 28 MB The pool also caps throughput, which is the point: queueing costs kilobytes, admitting costs megabytes.

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.

Four rules for binary data Pair every createObjectURL with a revoke. Copy small views that outlive their parent. Pool buffers on hot paths instead of allocating per operation. Verify against external rather than heapUsed. Each rule is paired with the failure it prevents. Four rules that remove almost every external-memory incident Pair createObjectURL with revokeObjectURL prevents: blobs held by the document store long after the code forgot them Copy views that outlive their parent prevents: a 64-byte header retaining an 8 KB slab, ten thousand times over Pool on hot paths prevents: peak memory scaling with throughput instead of with concurrency Verify against external, not heapUsed prevents: declaring a fix that released only the hundred-byte wrappers

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.