Typed Arrays, Buffers and External Memory

The most confusing memory investigations are the ones where the heap looks healthy and the process is still being killed, and this guide inside JavaScript Memory Fundamentals & Runtime Mechanics explains why: binary data does not live in the JavaScript heap at all. An ArrayBuffer, a Blob, a canvas bitmap and a WebAssembly linear memory are all allocated outside the V8 heap spaces, represented on the heap only by a small wrapper — which is exactly why a heap snapshot can report a 40 MB heap on a process consuming 900 MB.

Conceptual Grounding: Wrappers and Backing Stores

Every binary object in JavaScript is a pair: a small wrapper object on the V8 heap, and a backing store allocated through the engine’s array-buffer allocator or, in the browser, by the rendering engine. The wrapper is what the collector tracks. The backing store is freed when the wrapper is collected, which sounds equivalent but is not, because the collector’s decision to run at all is driven by heap pressure — and the wrapper contributes about a hundred bytes to that pressure regardless of whether it points at ten kilobytes or ten megabytes.

That asymmetry produces the characteristic failure mode. Allocate a hundred 8 MB buffers, drop the references, and the heap grows by roughly ten kilobytes. V8 sees no reason to collect. The process, meanwhile, is holding 800 MB the kernel counts and the container limit counts. V8 does apply an external-memory pressure heuristic to nudge collections along, but it is a nudge, not a guarantee, and it is easily outrun by an allocation loop.

The same structure explains the other surprises. A canvas element’s bitmap is four bytes per pixel — a 4000×3000 canvas is 48 MB whatever is drawn on it — held by the rendering engine and released when the element is dropped and its dimensions are zeroed. A Blob created by URL.createObjectURL is retained by the URL entry until revokeObjectURL is called, whether or not any variable still points at the blob. A WebAssembly.Memory grows in 64 KB pages and never shrinks.

Small wrappers, large backing stores Three wrapper objects of roughly one hundred bytes each sit on the V8 heap. Each points at an allocation outside the heap: an eight megabyte ArrayBuffer, a forty-eight megabyte canvas bitmap, and a two hundred and fifty-six megabyte WebAssembly linear memory. The heap total is a few hundred bytes; the process total is over three hundred megabytes. What the collector sees on the left; what the kernel counts on the right V8 heap · ~300 bytes ArrayBuffer wrapper · 96 B HTMLCanvasElement · 112 B WebAssembly.Memory · 88 B External memory · 312 MB backing store · 8 MB of decoded audio bitmap · 4000 × 3000 × 4 B = 48 MB linear memory · 4 096 pages = 256 MB A heap snapshot reports the left box. Container limits, OOM killers and phone memory pressure care only about the right one.

Diagnostic Workflow

  1. Establish the size of the gap. Command (Node.js): const m = process.memoryUsage(); console.log(m.heapUsed, m.external, m.rss). Expected: external well under 50 MB on a service that does not process binary data; anything larger is your subject.
  2. In the browser, measure the footprint, not the heap. Command: await performance.measureUserAgentSpecificMemory() — requires cross-origin isolation. Expected: a breakdown by type where canvas and typed-array bytes appear; the DevTools JS Heap counter will not show them.
  3. Find the owners, not the buffers. DevTools path: Memory → Heap snapshot → Summary → filter ArrayBuffer, then read Retainers. Expected: a small number of owning objects — a pool, a cache, an array of decoded frames — rather than thousands of independent allocations.
  4. Check each owner against its release API. Expected: blobs paired with revokeObjectURL, ImageBitmaps with close(), canvases with width = height = 0, workers terminated rather than abandoned.
  5. Re-measure external after the workload, not the heap. Expected: external returns to within a few megabytes of baseline; if only heapUsed does, nothing has actually been released.

Code Patterns & Signatures

Watching external memory alongside the heap. The two numbers diverging is the entire diagnosis.

// Sample both and report the ratio — a rising ratio means binary data,
// not object retention, is driving the growth.
setInterval(() => {
  const { heapUsed, external, arrayBuffers, rss } = process.memoryUsage();
  const mb = (n) => (n / 1048576).toFixed(1);
  console.log(
    `heap ${mb(heapUsed)} MB · external ${mb(external)} MB ` +
    `(of which ArrayBuffers ${mb(arrayBuffers)} MB) · rss ${mb(rss)} MB`
  );
}, 10_000).unref();

Pooling buffers so external memory has a ceiling by construction rather than by hope.

// A fixed pool: allocation happens once, reuse is free, and the peak is
// exactly poolSize × bufferBytes no matter how much data flows through.
function createBufferPool({ size = 8, bytes = 1 << 20 } = {}) {
  const free = Array.from({ length: size }, () => new Uint8Array(bytes));
  return {
    acquire() {
      // Falling back to a fresh allocation keeps correctness; log it so a
      // permanently empty pool shows up as a sizing problem, not a leak.
      return free.pop() ?? new Uint8Array(bytes);
    },
    release(buf) {
      buf.fill(0);                    // avoid leaking previous contents
      if (free.length < size) free.push(buf);
    },
  };
}

Releasing browser-side binary resources explicitly, in the order that actually frees memory.

function disposeFrame(frame) {
  // 1. ImageBitmap holds a decoded bitmap until closed — GC is not enough.
  frame.bitmap?.close();

  // 2. A blob URL keeps its blob alive even with no variable referencing it.
  if (frame.objectUrl) URL.revokeObjectURL(frame.objectUrl);

  // 3. A canvas backing store is 4 bytes/pixel; zeroing the dimensions
  //    releases it immediately instead of at some later collection.
  if (frame.canvas) {
    frame.canvas.width = 0;
    frame.canvas.height = 0;
  }

  // 4. Only now is dropping the references meaningful.
  frame.bitmap = frame.canvas = frame.objectUrl = null;
}
Heap flat, external climbing — the signature Over two minutes of image processing the JavaScript heap oscillates between thirty and forty megabytes with no trend, while external memory rises from twenty to six hundred and forty megabytes. The process is killed at the container limit with the heap still reading under forty megabytes. Two minutes of thumbnail generation, both series on one axis 0 350 MB 700 MB elapsed (0 → 120 s) container limit 640 MB heapUsed: 30–40 MB, no trend external: 20 → 640 MB OOMKilled here Every heap-based alert on this service stayed green for the entire two minutes.

Where External Memory Hides in a Typical Application

Most teams discover external memory the hard way, so it is worth listing the places it accumulates in ordinary applications that do not think of themselves as binary-data workloads.

Image and media pipelines. Every decoded image is a bitmap at four bytes per pixel, independent of the compressed file size — a 300 KB JPEG at 4000×3000 becomes 48 MB once decoded. An ImageBitmap created for a canvas draw holds a second copy until close() is called. Applications that build thumbnail grids by decoding full-resolution originals routinely hold a gigabyte of bitmaps while the JavaScript heap reads under 50 MB.

File upload and download paths. Reading a file with arrayBuffer() materialises the whole thing outside the heap. So does buffering a download before writing it. Both are invisible to heap-based instrumentation, and both scale with the user’s file rather than with anything the application controls, which makes them the most common source of a memory incident that cannot be reproduced with test data.

Streaming and compression in Node.js. Every chunk in flight is a Buffer, and every buffered chunk in a writable stream that is ignoring backpressure is external memory. A compression transform holds an internal window per stream, so a thousand concurrent gzip streams is a fixed cost measured in hundreds of megabytes before any payload is counted.

Native modules and their allocators. Database drivers, image processors and crypto libraries allocate through their own paths. Their memory shows in external and rss but never in arrayBuffers, and it is not released by any JavaScript-side action other than dropping the handle the module gave you — which is why a driver’s own close() or destroy() call is not optional.

WebAssembly modules. Linear memory grows in 64 KB pages and never shrinks. A module that processes one large document permanently sizes its memory to that document for the life of the instance, so the peak of the whole session becomes the steady state.

The common thread is that none of these are visible in a heap snapshot, and all of them are counted by the container limit and by the phone’s memory pressure signal. Any application touching two or more of them needs external on its dashboard next to heapUsed, or its memory alerting is measuring the wrong thing.

Symptom-to-Fix Reference Table

Symptom Root Cause Immediate Action Measurable Impact
Process killed with a small, flat heap Growth is entirely in external memory Chart external and arrayBuffers next to heapUsed Correct layer identified before any code changes
Snapshot shows tiny ArrayBuffer objects Snapshot records wrappers, not backing stores Read external instead; use the snapshot only to find owners Stops the investigation chasing the wrong number
Memory falls only after an unrelated collection Wrapper too small to create heap pressure Pool buffers, or drop references promptly and explicitly External memory tracks the workload instead of lagging it
Canvas-heavy page grows with each render Backing stores retained until dimensions are zeroed Set width = height = 0 before dropping the element Releases 4 bytes per pixel per canvas immediately
Blob memory never returns Object URL never revoked Call URL.revokeObjectURL on every created URL Blob bytes released at the call, not at reload
WebAssembly memory only ever grows Linear memory cannot shrink Cap it at instantiation; recreate the instance between jobs Peak becomes the declared maximum rather than unbounded
Every external allocation has a release call Five kinds of external memory with the call that frees each: an ArrayBuffer needs its last reference dropped, a Blob URL needs revokeObjectURL, an ImageBitmap needs close, a canvas needs its dimensions zeroed, and a WebAssembly memory needs the whole instance discarded. Five allocations, five release calls — none of them is garbage collection alone Allocation Released by If you forget ArrayBuffer / Buffer dropping the last reference held until a collection runs Blob behind an object URL URL.revokeObjectURL(url) held until the document dies ImageBitmap bitmap.close() decode held indefinitely Canvas backing store width = height = 0 4 bytes per pixel retained WebAssembly.Memory discarding the instance never shrinks at all

Edge Cases & Gotchas

arrayBuffers is a subset of external. Node reports both; external also includes memory held by native addons and internal C++ objects. If external grows while arrayBuffers does not, the source is a native module rather than your own buffers, and no amount of JavaScript-side pooling will help.

Transferring is not copying — but slicing is. Sending a buffer to a worker with a transfer list moves the backing store at no cost; the same call without the transfer list clones every byte and briefly doubles external memory, as covered in transferring ArrayBuffers versus copying. slice() always copies.

Small Buffer allocations share a pool in Node.js. Buffers under 4 KB are carved out of a shared 8 KB slab, so a single small buffer can keep the whole slab alive. When retaining thousands of tiny buffers long-term, copy them into a right-sized allocation with Buffer.from rather than keeping the slices.

Cross-origin isolation is required to measure the browser footprint. performance.measureUserAgentSpecificMemory() needs COOP and COEP headers. Without them you have no in-page way to see external memory at all, and must fall back to the Chrome Task Manager’s memory footprint column.

Detached buffers still occupy their wrapper. After a transfer, the sender’s ArrayBuffer object remains on the heap with byteLength of zero until it is collected. It costs almost nothing, but a Map of thousands of detached buffers is still a Map of thousands of entries.

The practical summary is short. Any application that decodes images, uploads files, streams responses, loads a WebAssembly module or draws to a canvas is holding memory that its heap instrumentation cannot see. Add external to the dashboard, pair every allocation with an explicit release, and prefer a bounded pool to a fresh allocation per unit of work — those three habits remove almost every incident of this class before it reaches production.

Treat every one of those habits as cheap insurance: none of them costs measurable runtime, and together they keep the process footprint proportional to the work actually in flight rather than to everything the process has ever touched.

FAQ

Why does a heap snapshot show only a few kilobytes for a 200 MB buffer?

The snapshot records the JavaScript wrapper object, not the backing store. An ArrayBuffer’s bytes are allocated outside the V8 heap, so the snapshot shows a small object with a large logical size while the real allocation appears only in process.memoryUsage().external or in the process footprint.

Does garbage collection free ArrayBuffer memory?

Yes, eventually. The backing store is released when the wrapper becomes unreachable and is collected, but because the wrapper is tiny, V8 feels little pressure to run a collection. A process can therefore hold hundreds of megabytes of dead buffers with a heap that looks almost empty until an unrelated collection finally runs.

Why does canvas memory not go down when I remove the element?

Removing a canvas from the document does not release its backing bitmap while any JavaScript reference remains, and the bitmap is four bytes per pixel regardless of what is drawn on it. Set width and height to zero before dropping the reference to release it immediately rather than at some later collection.

Can WebAssembly memory shrink?

A WebAssembly.Memory can grow but never shrinks for the life of the instance. The only way to return linear memory to the system is to discard the whole instance and its memory object, so long-lived modules should be given a maximum size at instantiation and, where the workload is bursty, be recreated between jobs.