DEV Community

Cover image for Your 3 MB JPEG Can Use 90 MB of Memory: Building Browser Image Tools That Survive Real Batches
Muhaymin Bin Mehmood
Muhaymin Bin Mehmood

Posted on

Your 3 MB JPEG Can Use 90 MB of Memory: Building Browser Image Tools That Survive Real Batches

A file picker says a photo is 3 MB. Your browser opens it, draws it into a canvas, produces a new file, and suddenly a batch of those photos makes the tab struggle.

The missing detail is that compressed file size and decoded image memory are different budgets.

A small JPEG can describe a very large pixel grid. Once an application starts manipulating that grid, the number printed beside the filename stops being a useful estimate of the whole job's memory cost.

If you are building a converter, cropper, upload preview, or document scanner, this distinction changes how you design the pipeline.

This article presents a reference design, not a claim that every technique below is implemented in BatchSet. The numerical examples are calculations, not benchmark results.

1. Start with pixels, not megabytes on disk

For an illustrative 8-bit RGBA pixel buffer, the basic calculation is:

const bytes = width * height * 4;
const mebibytes = bytes / (1024 * 1024);
Enter fullscreen mode Exit fullscreen mode

A 6000 × 4000 image contains 24 million pixels. Four bytes per pixel gives 96 million bytes, or approximately 91.6 MiB, for one such buffer.

That is not a promise about a browser's exact allocation. Internal formats, GPU resources, decoder behavior, and implementation details vary. It is a planning example that explains why a 3 MB compressed file can lead to much more memory use during editing.

Now consider a job that keeps a source bitmap, a destination canvas, an encoded output, and a preview alive at once. Some resources may be shared; others may be copied. Assuming only the original file size is unsafe.

Resource What determines its size? When should it stop being retained?
Original file Encoded input bytes When the user removes it or ends the job
Decoded image Pixel dimensions and representation After processing finishes
Canvas Destination dimensions and representation After encoding finishes
Output Blob Encoded result After export or explicit removal
Preview URL Reference to preview data When the preview is replaced or removed

The interesting question is not only “How large is each file?” It is “How many expensive representations exist at the same time?”

2. Do not decode the entire queue at once

This is convenient:

await Promise.all(files.map(convertImage));
Enter fullscreen mode Exit fullscreen mode

It is also an invitation for every conversion to begin before the first one releases its resources.

Promises do not create a memory budget. A queue needs an explicit concurrency limit.

Here is a small reference scheduler. It preserves input order, records per-file failures, and checks cancellation before starting each new item:

async function processBatch(files, convert, {
  concurrency = 2,
  signal,
  onSettled = () => {},
} = {}) {
  if (!Number.isInteger(concurrency) || concurrency < 1) {
    throw new RangeError("concurrency must be a positive integer");
  }

  const results = new Array(files.length);
  let nextIndex = 0;

  async function runWorker() {
    while (true) {
      signal?.throwIfAborted();
      const index = nextIndex++;
      if (index >= files.length) return;

      let result;
      try {
        const value = await convert(files[index], { signal });
        result = { status: "fulfilled", value };
      } catch (error) {
        if (signal?.aborted) throw error;
        result = { status: "rejected", reason: error };
      }

      results[index] = result;
      onSettled({ index, result });
    }
  }

  await Promise.all(
    Array.from(
      { length: Math.min(concurrency, files.length) },
      runWorker,
    ),
  );
  return results;
}
Enter fullscreen mode Exit fullscreen mode

This limits active conversions. It does not limit the total bytes of successful outputs retained in results. That second budget matters later.

Two workers is a starting configuration, not an optimal setting for every device. Benchmark representative workloads before increasing it. A batch of tiny thumbnails and a batch of camera originals need different treatment.

For more control, reserve an estimated memory cost before starting each task. Treat such reservations as conservative scheduling hints, not measurements of actual browser memory. Dimensions may only become known after some decoding has already happened.

3. Workers keep work off the UI thread; they do not create RAM

Web Workers run code separately from the main UI thread. That can help keep buttons, progress indicators, and scrolling responsive.

They do not make decoding free. More workers can mean more live image resources.

When transferring an ArrayBuffer, ownership can move to the recipient rather than copying the buffer. The sender's transferred buffer becomes detached. This can reduce avoidable copies, but it is not a universal zero-copy guarantee for an entire image pipeline.

Decide ownership explicitly:

  • Does the UI retain the original input?
  • Does a worker own the decoded bitmap?
  • Who holds the resulting Blob?
  • Which component releases a preview?

A resource with no clear owner is a resource that tends to survive longer than intended.

4. Cleanup belongs in the conversion contract

Success and failure should both release temporary resources.

For example, ImageBitmap.close() explicitly disposes of the bitmap's graphical resources. See the API reference.

async function encodeWebP(file, { signal, quality = 0.8 } = {}) {
  let bitmap;
  let canvas;

  try {
    signal?.throwIfAborted();
    bitmap = await createImageBitmap(file);
    signal?.throwIfAborted();

    canvas = document.createElement("canvas");
    canvas.width = bitmap.width;
    canvas.height = bitmap.height;

    const context = canvas.getContext("2d");
    if (!context) throw new Error("2D canvas unavailable");
    context.drawImage(bitmap, 0, 0);

    const blob = await new Promise((resolve, reject) => {
      canvas.toBlob(
        result => result
          ? resolve(result)
          : reject(new Error("Encoding failed")),
        "image/webp",
        quality,
      );
    });

    signal?.throwIfAborted();
    if (blob.type !== "image/webp") {
      throw new Error("WebP encoding unavailable");
    }
    return blob;
  } finally {
    bitmap?.close();
    if (canvas) {
      canvas.width = 0;
      canvas.height = 0;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This browser example intentionally has a narrow scope: one still image, no resizing, no metadata-preservation guarantee, and no animation handling. It also checks the resulting MIME type rather than assuming the requested encoding succeeded; the toBlob() reference documents format fallback and possible encoding failure.

Resetting canvas dimensions clears its drawing state and removes the application's need for the old surface. Exact memory reclamation timing remains browser-dependent.

Do not present this snippet as a hardened decoder for arbitrary hostile images. Production limits, supported-format checks, and unusually large pixel dimensions need additional handling.

5. Preview cleanup is different from conversion cleanup

A converter can close every bitmap correctly while the interface keeps hundreds of full-resolution preview references alive.

Object URLs are especially easy to forget:

const previewURL = URL.createObjectURL(blob);
imageElement.src = previewURL;
Enter fullscreen mode Exit fullscreen mode

When that preview is no longer needed, release its URL:

imageElement.removeAttribute("src");
URL.revokeObjectURL(previewURL);
Enter fullscreen mode Exit fullscreen mode

MDN documents revokeObjectURL() as the method for releasing an object URL when its consumer has finished using it.

Do not revoke a URL immediately while a visible preview or pending download still depends on it. Prefer cleanup on replacement, removal, or component teardown.

A practical interface can also generate smaller preview images, virtualize long lists, and avoid keeping full-resolution decoded previews mounted for every item. Those choices reduce retention; they do not alter the user's original files.

6. The ZIP can become your next memory peak

Bounded decoding only solves one phase.

If an application holds all output Blobs, passes them to an archive library, and then generates a complete ZIP Blob, several substantial allocations may coexist. The precise behavior depends on the archive implementation.

Before calling a library “streaming,” check its output path. An internal streaming interface can still end in a fully materialized archive.

For a reference design, choose among:

  • Smaller export groups, with a clear batch boundary.
  • Individual downloads for unusually large jobs.
  • A supported writable-file path with a genuinely streaming archive implementation.
  • A documented maximum export budget.

Do not silently drop successful outputs to make an export fit. Explain what remains available and how the user can retrieve it.

7. Cancellation must describe what actually stops

The scheduler checks an abort signal. That does not mean every browser decoder or encoder accepts that signal.

In the example, cancellation prevents new tasks from starting and rejects a result after an awaited operation returns. A native operation already underway may finish before cleanup occurs.

With dedicated workers, terminating a worker is a stronger way to stop its execution, but then the parent must mark in-flight tasks correctly and dispose of resources it owns.

For a clear UI, keep these states separate: queued, processing, succeeded, failed, and cancelled. Completed outputs should remain distinguishable from tasks that never ran.

8. Test the second batch, not just the first

My suggested verification sequence is:

  1. Run a representative batch of large photos.
  2. Remove previews and outputs using the normal UI.
  3. Run another batch in the same tab.
  4. Cancel during decoding, encoding, and export separately.
  5. Mix a corrupt input with valid files.
  6. Repeat on a lower-memory device.

Record completion, responsiveness, and whether resource use keeps rising across cycles. JavaScript heap measurements alone can miss browser-managed image and graphics allocations.

The goal is a pipeline that remains usable throughout the session, not merely a fast first conversion.

Put the workflow to use

BatchSet's basic bulk converter processes local images in the browser and exports a ZIP. If your immediate goal is preparing a folder of product or marketing images, you can try the Bulk Image Converter.

Keep batches appropriate for your device, inspect the results, and preserve your originals. Browser-based processing removes an upload step; a thoughtful memory budget makes that workflow sustainable.

Top comments (0)