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.
Diagnostic Workflow
- Establish the size of the gap. Command (Node.js):
const m = process.memoryUsage(); console.log(m.heapUsed, m.external, m.rss). Expected:externalwell under 50 MB on a service that does not process binary data; anything larger is your subject. - 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. - 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. - Check each owner against its release API. Expected: blobs paired with
revokeObjectURL,ImageBitmaps withclose(), canvases withwidth = height = 0, workers terminated rather than abandoned. - Re-measure
externalafter the workload, not the heap. Expected:externalreturns to within a few megabytes of baseline; if onlyheapUseddoes, 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;
}
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 |
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.
Related
- ArrayBuffer and Blob Memory Outside the JS Heap — measuring and releasing binary payloads
- WebAssembly Linear Memory Growth in the Browser — pages, maximums and why memory never shrinks
- Canvas and ImageBitmap Memory in Long-Running Apps — bitmaps, decoding and explicit disposal
- Understanding the V8 Heap Layout and Memory Segments — what does live on the heap, and where
- Production Memory Monitoring and Container Limits — exporting
externalalongside the heap gauges