DEV Community

Nina Hart
Nina Hart

Posted on

A 4 MB JPEG occupies 46 MB of RAM: decoded image memory in the browser

A photo taken on a modern phone is about 4 MB on disk. Open it in a browser and decode it, and that same photo now occupies roughly 46 MB.

That factor of twelve is the single most useful number to know when you build anything that touches images in a browser. It explains why a batch tool that works fine on five files dies on fifty, and why the failure usually arrives as a frozen tab rather than an error message.

Four bytes per pixel, always

A JPEG stores one value per pixel per colour channel, compressed. A canvas does not. Once an image is decoded for drawing or pixel manipulation, the browser holds it as raw samples, four bytes per pixel:

  • one byte red
  • one byte green
  • one byte blue
  • one byte alpha

No compression, no subsampling, no cleverness. It is a flat buffer, because that is the layout that makes drawImage and putImageData fast.

So the memory cost is not a property of the file at all. It is a property of the dimensions:

const bytes = width * height * 4;
Enter fullscreen mode Exit fullscreen mode

That is the whole formula. Everything else follows from it.

The table

Dimensions Megapixels Decoded (RGBA)
1000 × 1000 1.0 3.8 MB
2000 × 2000 4.0 15.3 MB
3000 × 2000 6.0 22.9 MB
4000 × 3000 12.0 45.8 MB
6000 × 4000 24.0 91.6 MB
8000 × 6000 48.0 183 MB

A 12-megapixel photo is 45.8 MB decoded. Twenty of them, held at once, is just under a gigabyte. Not twenty files — twenty images, each of which was only 4 MB on disk.

This is also why megapixels are the number that matters and file size is not. A heavily compressed 24 MP photo can be smaller on disk than a lightly compressed 12 MP one, and still cost twice the memory.

getImageData gives you a second copy

Here is the part that catches people, because the API does not advertise it:

const data = ctx.getImageData(0, 0, w, h);
Enter fullscreen mode Exit fullscreen mode

That call does not hand you a view into the canvas. It returns a new Uint8ClampedArray containing a copy. So while that array is alive, you are holding:

  • the canvas backing store — w × h × 4
  • the ImageData copy — another w × h × 4

Two times the table above. A 2000 × 2000 canvas and its ImageData is 30.5 MB, not 15.3 MB.

If the code then builds a second canvas to write results into, that is a third buffer. Three full-size allocations for what feels like one operation is completely normal in image-processing code, and it is worth counting before blaming the browser.

Retention, not concurrency, is what kills the tab

The intuitive fix is "process one file at a time". That helps, but it is not the main thing.

The main thing is whether you keep a reference to the decoded image after you are done with it.

// This looks sequential. It is not safe.
const decoded = [];
for (const file of files) {
  const img = await decode(file);
  decoded.push(img);           // 46 MB, retained, per file
}
Enter fullscreen mode Exit fullscreen mode

The loop awaits one file at a time, so only one decode is in flight. But every previous bitmap is still referenced by decoded, so by file forty the tab is holding 1.8 GB and the browser is allowed to reclaim it by any means it likes — usually by discarding the tab, which the user experiences as a page that reloaded itself for no reason.

Compare:

for (const file of files) {
  const img = await decode(file);
  const out = await process(img);
  await save(out);
  // img goes out of scope here; its buffer becomes collectable
}
Enter fullscreen mode Exit fullscreen mode

Same concurrency, very different ceiling. Memory now depends on the largest single image, not the batch length.

There is a wrinkle worth knowing: garbage collection is not guaranteed to run when you drop a reference. It runs when the engine decides to. So the practical version of this pattern also avoids allocating anything that contains the bitmap — no storing img on the item record "just in case the user retries", no keeping an ImageBitmap in a preview list.

What a freeze actually looks like

When a tab exceeds what it is allowed, the usual outcomes, in rough order of likelihood:

  1. The tab becomes unresponsive. The main thread is busy decoding, so click handlers queue up and nothing repaints. This is not a crash, it is a slow loop, and it looks identical to a hang.
  2. The tab reloads itself. On desktop this is often a deliberate out-of-memory recovery. On mobile it is normal, because the OS is reclaiming memory from the whole browser.
  3. canvas.toBlob returns null. Encoding failed, usually because the canvas exceeded a size limit or the allocation did not succeed.

Option 3 is the only one that gives you something to catch. Options 1 and 2 take the page state with them, which is why a batch tool should write its progress somewhere durable and not rely on being able to finish.

The exact limits are not worth memorising, because they vary by browser, by device, and by platform. Mobile Safari is far stricter than desktop Chrome, and a canvas that works on a laptop can fail on a phone with the same code. Design for the phone.

Three patterns that actually help

1. Chained serial queue. Not Promise.all. One item at a time, with the next started only when the previous settles:

function runQueue(items, process) {
  let i = 0;
  function step() {
    if (i >= items.length) return;
    const item = items[i++];
    process(item).then(step, step);   // continue on failure too
  }
  step();
}
Enter fullscreen mode Exit fullscreen mode

Two details matter more than they look: then(step, step) so one bad file does not stall the whole batch, and starting the next item in a promise callback rather than a for loop, so each decode happens after the previous frame has had a chance to paint.

2. Cap the working resolution. Do the pixel work at a bounded size, then render the final output at the size the user asked for:

const MAX = 1000;                                  // longest edge for processing
const scale = Math.min(1, MAX / Math.max(w, h));
const pw = Math.max(1, Math.round(w * scale));
const ph = Math.max(1, Math.round(h * scale));
Enter fullscreen mode Exit fullscreen mode

This turns a 4000 × 3000 photo from 45.8 MB of working memory into 2.9 MB — it becomes 1000 × 750 — and it is the difference between a tool that handles a hundred files and one that handles eight.

3. Revoke object URLs, and revoke the previous one. Every createObjectURL pins a blob until it is revoked:

if (outUrl) URL.revokeObjectURL(outUrl);   // before replacing
outUrl = URL.createObjectURL(blob);
Enter fullscreen mode Exit fullscreen mode

A batch that creates one URL per output and revokes none leaks the entire output set, which after zipping is not trivial.

The cost of capping, stated honestly

Pattern 2 is a trade-off, not a free win. If you compute a mask at 1000 px and then scale it up to a 2000 px output, the mask is being interpolated. Edges that were resolved per-pixel at 1000 px are now stretched over two pixels each.

For a threshold operation — background removal, chroma key — this shows up as a slightly soft fringe on the boundary. For a pure geometric resize it does not matter at all, because there is no per-pixel decision being made.

The honest way to handle it is to make the cap visible: process at preview resolution for interactivity, and offer a separate export that runs at full resolution when the user actually needs it. Doing the pixel work at full resolution for a live preview is what makes those tools feel broken — every slider drag allocates a 46 MB buffer.

Sometimes the browser is the wrong tool

The upper bound is real. A 100-megapixel scan decodes to about 380 MB before you do anything with it, and a phone will not survive that no matter how the code is arranged. At that point the answer is a desktop tool or a native library, and no amount of worker threads fixes a memory budget.

The useful rule of thumb: if the total decoded size of the batch is under a few hundred megabytes and the images are not larger than about 12 megapixels, a browser tab is a perfectly good environment. Above that, it is the wrong place and the friction is not your bug.

Measuring it yourself

Chrome exposes a non-standard but useful number:

console.log(performance.memory.usedJSHeapSize / 1048576, 'MB');
Enter fullscreen mode Exit fullscreen mode

Log it before and after decoding one image. The delta should roughly match w × h × 4 plus encoder overhead. If it is much larger, something is being retained that should not be — and now you know where to look.


I wrote this while putting together a browser-based batch photo resizer that runs entirely on your own machine. The arithmetic above is what decided its limits: it processes serially, works at a capped resolution for the preview, and revokes URLs as it goes. The user-facing version of the same conclusions is in this guide on processing large batches, if you would rather read the operational version than the memory version.

Top comments (0)