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.

The same canvas at three device pixel ratios A canvas measuring one thousand by eight hundred CSS pixels has a backing store of three point two megabytes at ratio one, twelve point eight at ratio two, and twenty-eight point eight at ratio three. The cost grows with the square of the ratio, not linearly. One 1000 × 800 CSS-pixel canvas, three devices ratio 1 · 1000 × 800 3.2 MB ratio 2 · 2000 × 1600 12.8 MB ratio 3 · 3000 × 2400 28.8 MB — nine times the ratio-1 cost for an identical-looking element Ten such canvases in a dashboard is 288 MB on a ratio-3 phone — more than the whole tab budget on many devices.

Step-by-Step Fix

  1. Do the arithmetic first. Command: canvas.width * canvas.height * 4 / 1048576 for every canvas on the page. Expected: a total you can compare against the device budget before writing any code.
  2. 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.
  3. Close bitmaps after drawing. Verification: the browser memory measurement stops climbing per thumbnail.
  4. Zero dimensions on teardown. Verification: footprint drops at the moment of teardown rather than at some later collection.
  5. 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);
}
Scrolling 200 thumbnails, two decode strategies Scrolling through two hundred images while decoding at full resolution drives page memory from ninety megabytes to over nine hundred and the tab is reloaded by the browser. Decoding at three hundred and twenty pixels wide and closing each bitmap holds memory at about one hundred and forty megabytes across the same scroll. Same 200 images, same scroll, two decode policies 0 500 MB 1 GB images scrolled past (0 → 200) tab reload threshold full-size decode, never closed resized decode + close() → flat at ~140 MB The green run also scrolls faster: decoding 320 px wide is a fraction of the work of decoding 4000 px wide.

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.

Four decisions, and what each costs per image Decoding full resolution costs forty-eight megabytes per image; decoding at display width costs zero point three. Leaving an ImageBitmap open costs its full decode; closing it costs nothing. Rendering at ratio three costs two point twenty-five times ratio two. Leaving a canvas undisposed costs four bytes per pixel until collection. Per-image arithmetic for a 4000 × 3000 source Decision Cost per image Decode at full resolution 48 MB Decode at 320 px display width 0.3 MB Leave the ImageBitmap open the whole decode, until collection Render at devicePixelRatio 3 instead of 2 2.25× the backing store The first two rows differ by 160×, which is why decode size is the first thing to fix.

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.