Canvas and ImageBitmap Memory in Long-Running Apps
Image-heavy applications routinely hold more memory in decoded bitmaps than in everything else combined, and none of it appears in a heap snapshot. This page belongs to typed arrays, buffers and external memory in JavaScript Memory Fundamentals & Runtime Mechanics, and covers the arithmetic and the release calls.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Gallery page grows with every scroll | Full-resolution decodes retained per thumbnail | Decode at display size with resizeWidth |
| Memory fine on desktop, kills a phone | Backing store scaled by device pixel ratio squared | Cap the effective pixel ratio you render at |
| Removing canvases changes nothing | Bitmaps released only on collection | Zero width and height before dropping |
ImageBitmaps accumulate |
close() never called |
Close after the draw, in a finally |
| Heap snapshot shows nothing unusual | Bitmaps live outside the JavaScript heap | Measure with the browser memory API instead |
Root Cause: Four Bytes Per Pixel, Twice Over
A canvas backing store is a raw bitmap: width × height × 4 bytes, always, whatever is painted on it. A blank 4000 × 3000 canvas costs 48 MB the moment it is created. This is the arithmetic that surprises people, because nothing about the API suggests that an empty canvas is expensive.
Device pixel ratio multiplies it quadratically. Sizing a canvas for a high-density display — the standard canvas.width = cssWidth * devicePixelRatio pattern — turns a 1000 × 800 CSS-pixel element into a 2000 × 1600 backing store on a ratio-2 device, and 3000 × 2400 on a ratio-3 phone. That is 3.2 MB, 12.8 MB and 28.8 MB for the same visual element.
Decoded images are the same currency. An ImageBitmap holds a decoded, ready-to-draw bitmap at four bytes per pixel, independent of the compressed source: a 300 KB JPEG at 4000 × 3000 becomes 48 MB once decoded. A gallery that creates one per thumbnail without closing them accumulates decodes far faster than any network cost would suggest.
Release is explicit in both cases. ImageBitmap.close() frees the decoded data immediately; without it the data lives until the wrapper is collected, and the wrapper is small enough that the collector is in no hurry. For a canvas, setting width and height to zero releases the backing store at that moment; otherwise it survives until the element is collected, which requires every reference — including any lingering getContext result — to be gone.
Step-by-Step Fix
- Do the arithmetic first. Command:
canvas.width * canvas.height * 4 / 1048576for every canvas on the page. Expected: a total you can compare against the device budget before writing any code. - Cap the effective pixel ratio. Render at
Math.min(devicePixelRatio, 2). Verification: memory on a ratio-3 device falls by more than half with no visible difference at normal viewing distance. - Close bitmaps after drawing. Verification: the browser memory measurement stops climbing per thumbnail.
- Zero dimensions on teardown. Verification: footprint drops at the moment of teardown rather than at some later collection.
- Decode at display size.
createImageBitmap(blob, { resizeWidth: 320, resizeQuality: 'high' }). Verification: per-thumbnail cost falls from tens of megabytes to a few hundred kilobytes.
Command & Code Reference
Sizing a canvas for a high-density display without paying the ratio-3 penalty.
// A capped ratio: visually indistinguishable at normal viewing distance,
// and up to 2.25× cheaper on a ratio-3 phone.
const MAX_RATIO = 2;
function sizeCanvas(canvas, cssWidth, cssHeight) {
const ratio = Math.min(window.devicePixelRatio || 1, MAX_RATIO);
canvas.width = Math.round(cssWidth * ratio); // backing store
canvas.height = Math.round(cssHeight * ratio);
canvas.style.width = `${cssWidth}px`; // CSS size, unchanged
canvas.style.height = `${cssHeight}px`;
const ctx = canvas.getContext('2d');
ctx.scale(ratio, ratio); // draw in CSS pixels
return ctx;
}
Decoding at the size you will actually display, which is where most of the saving lives.
// A 4000×3000 source decoded full-size is 48 MB. Decoded at 320 px wide it
// is about 0.3 MB — and the thumbnail looks identical.
async function loadThumbnail(blob, targetWidth = 320) {
const bitmap = await createImageBitmap(blob, {
resizeWidth: targetWidth,
resizeQuality: 'high',
});
try {
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
return canvas;
} finally {
bitmap.close(); // release the decode immediately, not eventually
}
}
Tearing down a canvas so the bitmap is released at that instant.
function disposeCanvas(canvas) {
// Zeroing the dimensions frees the backing store synchronously. Without
// this, 4 bytes/pixel stay resident until the element is collected —
// which may be long after it left the document.
canvas.width = 0;
canvas.height = 0;
canvas.remove();
}
// In a virtualised list, dispose the canvases that scrolled out of range.
function recycleRow(row) {
for (const canvas of row.querySelectorAll('canvas')) disposeCanvas(canvas);
}
Verification & Regression Prevention
Verify with the browser’s memory measurement API rather than the DevTools heap counter, because none of this is on the JavaScript heap. In a cross-origin-isolated page, performance.measureUserAgentSpecificMemory() returns a breakdown that includes canvas and image bytes; without isolation, the Chrome Task Manager’s memory footprint column is the fallback.
For prevention, put the disposal in the same helper as the creation — a createManagedCanvas that returns both the context and a dispose function — so the two cannot drift apart. Then add a scroll test that measures footprint at the start and after scrolling a few hundred items, asserting the delta stays under a fixed budget. That single test covers unclosed bitmaps, undisposed canvases and oversized decodes at once, since all three show up as the same number.
FAQ
How much memory does a canvas use?
Four bytes per pixel of its backing store, regardless of content — a blank 4000 × 3000 canvas costs the same 48 MB as a fully painted one. On a device with a pixel ratio of 2 a canvas sized 1000 × 800 in CSS pixels has a 2000 × 1600 backing store, which is 12.8 MB rather than 3.2.
Why does removing a canvas element not free its memory?
Removal from the document only drops the tree link. The backing store is released when the element itself is collected, which requires every JavaScript reference to be gone and a collection to run. Setting width and height to zero first releases the bitmap at that moment instead.
Does an ImageBitmap need to be closed if I drop the reference?
It should be. An ImageBitmap owns a decoded bitmap held by the rendering engine, and while it is eventually released when the wrapper is collected, the wrapper is small enough that collection may be a long time coming. close() releases the decoded data immediately and deterministically.
Related
- Typed Arrays, Buffers and External Memory — the parent guide to memory the heap does not count
- ArrayBuffer and Blob Memory Outside the JS Heap — the blobs these decodes usually come from
- Detached DOM Nodes and Memory Retention — what happens when the canvas element itself is retained