DEV Community

hao jia
hao jia

Posted on

Reading a canvas back doubles its memory: numbers from three engines


Our product-image component crops, watermarks and samples a dominant color from every upload. All three steps call getImageData on the full canvas. For a long time our memory estimate for a large image was "width × height × 4". That number is right for the canvas itself, and it is exactly half of what the component actually holds once it reads the pixels back.

What a full-canvas getImageData really costs

The numbers below come from a round of measurements on an Apple M4 with 16 GB, using three engine builds: the Chromium 149 open-source build, a WebKit 26.5 build and a Firefox 151 build. None of them is the browser you download, so read nothing here as Chrome, Safari or release Firefox behavior. Memory was measured at the OS level by summing phys_footprint across every browser process of that launch, because nothing inside the page can see canvas memory.

Line chart of browser memory increase against canvas pixels for three engines, after fill and after a full read-back

The x axis is canvas pixels in millions, the y axis is MiB above the idle page. Each engine has a dark line for "after fill" and a light line for "after full getImageData". Look at the 268 tick, which is 16384×16384. Chromium's dark line sits at 1025 MiB and its light line at 2050 MiB, so the second copy is the whole ImageData, 1,073,741,824 bytes. Firefox lands almost on top of it (1025.3 and 2035.6 in the raw data), and its lines keep going to 537 because that build can draw up to 23168×23168. The light orange WebKit line is the outlier: 1206.4 MiB after the fill and 3265.4 MiB after the read-back at the same size. The WebKit build also carries tens of MiB of extra overhead on small canvases, 44.6 MiB for a filled 1024×1024 where the formula says 4 MiB. I quote WebKit figures per size and do not turn them into a ratio.

Two more details changed how I wrote the estimate. Creating a canvas and calling getContext('2d') without drawing added less than 1 MiB in all three engines, so the pixel buffer is allocated on first draw, not at creation. And setting width = height = 0 and dropping the reference did not bring the number down within 0.8 s: Chromium still showed +1025 MiB at 16384×16384. No GC was forced, so this is not evidence of a leak, only a reminder that "released" and "returned to the OS" are different moments.

I re-ran the three steps on one 8192×8192 canvas in Chromium 149 to see them in order. createElement plus getContext added +0.3 MiB. The fill brought it to +256.7 MiB, one frame of 256 MiB, and the read-back to +512.9 MiB. Meanwhile JSHeapUsedSize only moved from 949,212 to 1,115,836 bytes while half a gigabyte is allocated. That is why the numbers above come from the OS and not from the page.

The estimate I use now

The component computes a budget before anything is drawn:

function frameBudget(width, height, { readBack = false } = {}) {
  const pixels = width * height;
  const bytes = pixels * 4 * (readBack ? 2 : 1);
  return { pixels, mib: bytes / 2 ** 20 };
}
Enter fullscreen mode Exit fullscreen mode

I ran it in Node for the sizes above. 16384×16384 gives 1024 MiB without read-back and 2048 MiB with it, within 1% of what Chromium and Firefox measured. 8192×8192 gives 256 and 512 MiB. The WebKit build measured 823.3 MiB after read-back at 8192×8192, well above the formula, so our ceiling is set lower than the formula would allow instead of adding a per-engine multiplier I cannot justify.

I also wanted to know whether image tools that work in the browser keep large files local, since our compliance review cares about that more than about memory. I used ImgIng (https://imging.ai/), operating the Chinese interface, to convert a 10000×10000 JPEG to WebP and JPG on the same M4 across the same three engine builds. Across 12 cases there were zero non-GET requests. By the formula, that 100-megapixel image needs about 381 MiB just to be drawn once, and about 763 MiB if anything reads it back.

What I still don't know

The measurements stopped at safety thresholds on a machine that was also running other work, so I have no crash point to report. GPU-backed canvases were not covered either, and I never repeated any of this in release browsers.

If your pipeline calls getImageData on the full canvas, count that second copy in your budget. Then draw your largest allowed size in the browsers you ship to and read one corner pixel back to confirm it actually rendered.

Top comments (0)