DEV Community

PlainlyKit
PlainlyKit

Posted on Fully Autonomous

A byte limit is not a pixel limit: guardrails for browser image conversion

A 4 MB image file does not imply a 4 MB working set. Compressed input size and decoded raster size describe different things. That distinction matters when a browser tool accepts AVIF and exports PNG.

Disclosure: I work on PlainlyKit, which has a browser image converter. This article was prepared with AI assistance and checked against the implementation. The examples below are deliberately small teaching examples, not the complete converter.

Start with a memory estimate you can explain

For an ordinary 8-bit RGBA raster, a useful baseline is:

width Γ— height Γ— 4 bytes

For 5,000 Γ— 5,000 pixels, that is 100,000,000 bytes, or approximately 95.4 MiB, for one such raster. This is arithmetic, not a measurement of a browser's actual allocation.

The decoder, bitmap, canvas, encoded output and previews can coexist. Browser implementations may use other internal representations. A small compressed input can therefore lead to much more work than its file size suggests.

Use separate limits for compressed bytes, dimensions, pixel count and retained output. None is a substitute for the others.

Reject both excessive area and excessive edge length

Here is a dimension guard with a 25-million-pixel budget and a 16,384-pixel edge limit:

function assertRasterSize(width, height) {
  if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) ||
      width < 1 || height < 1) {
    throw new RangeError("Invalid image dimensions");
  }
  if (width > 16_384 || height > 16_384 ||
      width * height > 25_000_000) {
    throw new RangeError("Image exceeds this tool's raster budget");
  }
  return width * height;
}
Enter fullscreen mode Exit fullscreen mode

These are application limits, not universal browser capabilities. Passing the guard does not guarantee that a particular phone can allocate the required canvas.

The edge check catches a very long, narrow image that might fit the area budget. The area check catches a large square whose edges individually fit. Validate both before allocating the output canvas.

There is an important timing limitation: if you obtain dimensions by decoding with createImageBitmap(), decoding has already happened when this guard runs. It can prevent subsequent canvas allocation and output work; it cannot retroactively protect the decoder. A byte cap and format-header checks are still useful, and none of these checks is a complete defense against hostile image files.

Treat PNG encoding as a fallible operation

Canvas export is asynchronous. MDN documents that toBlob() can deliver null, so the download button should not assume that a file exists:

function pngBlob(canvas) {
  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => {
      if (!blob || blob.type !== "image/png" || blob.size === 0) {
        reject(new Error("PNG encoding did not produce a valid output blob"));
        return;
      }
      resolve(blob);
    }, "image/png");
  });
}
Enter fullscreen mode Exit fullscreen mode

This checks the returned blob's label and size; it is not a full PNG file validator. PNG is a required canvas output format, but memory and encoding failures still need an error path. Disable the previous download whenever a new conversion starts or inputs change, and only expose the new blob after success.

Bound the batch, not just each file

Ten files passing a per-file check can still be too much when decoded together. Process sequentially rather than decoding the whole selection with Promise.all(). Keep an aggregate input limit and an aggregate retained-output limit as well.

Sequential conversion reduces concurrent decoding work. It does not make memory constant if you retain every output blob or preview. Release previews you no longer need, and use ImageBitmap.close() when you are finished with an image bitmap. Cleanup also belongs on error and cancellation paths.

State the fidelity boundary

PNG compression does not restore detail already lost in an AVIF source. It stores the pixels handed to its encoder without another lossy compression step. A browser decode-and-canvas pipeline should not be presented as preserving every source metadata field, HDR characteristic or wide-gamut color value.

Likewise, a still-image example is not an animation converter. Decide how to handle animated input explicitly. Our implementation rejects animated AVIF rather than silently claiming that a single-frame PNG preserves the sequence.

In PlainlyKit's AVIF-to-PNG tool, the current limits are 10 still images, 20 MiB per file, 50 MiB total input, 25 million pixels per image, 16,384 pixels per edge and 50 MiB total retained output. The point is to expose practical limits before the user selects a large batch, not to promise a fixed peak-memory figure.

The accompanying local self-check exercised the dimension boundaries and the encoding wrapper's success, null, empty-blob and wrong-MIME paths using mocks. It did not measure browser memory, test actual AVIF conversion across browsers or establish color fidelity.

References: MDN: canvas.toBlob(), MDN: ImageBitmap.close().

Top comments (0)