Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Hitting an exact file size when compressing images in the browser

India's chartered accountancy board caps the photo you upload with your exam form at 50 KB. South Africa's tax service wants supporting documents under 2 MB. A French rental platform stops at 10 MB per file. Canada's imm

India's chartered accountancy board caps the photo you upload with your exam form at 50 KB. South Africa's tax service wants supporting documents under 2 MB. A French rental platform stops at 10 MB per file. Canada's immigration portal has its own number.

None of these portals will compress your file for you. They reject it and let you work it out.

A photo from a modern phone is 3 to 5 MB. Getting it to 50 KB means dividing its weight by about eighty, and the obvious approach — pick a JPEG quality, encode, hope — doesn't survive contact with real files. Here's what actually works, and why the naive version fails.

Quality is not a dial you can read

The temptation is to map file size onto quality: 50 KB is small, so use quality 0.3.

That fails because JPEG quality isn't a size setting. It's a quantization parameter, and how many bytes it produces depends entirely on the image. A photo of a blank wall at quality 0.9 can be smaller than a photo of a forest at quality 0.4. Noise, texture and detail decide the output size, not the number you passed in.

So you can't compute the quality you need. You have to find it.

Binary search

Encoding to a canvas is cheap enough to do repeatedly, which makes this a search problem. The quality range you care about is roughly 0.25 to 0.95 — below 0.25 the artifacts are bad enough that the result is useless anyway.

let lo = 0.25, hi = 0.95, found = null;
for (let i = 0; i < 7; i++) {
  const q = (lo + hi) / 2;
  const blob = await encodeImage(bmp, scale, q, type);
  if (blob.size <= target) { found = blob; lo = q; } else hi = q;
}

Seven iterations narrows a 0.7-wide range to about 0.005. That's far finer than the eye can tell apart, and it costs seven canvas encodes, which on a phone photo runs in well under a second.

Note what the loop keeps. Every time it lands under the target it stores that blob in found and searches upward, because a bigger file that still fits is a better file. It never returns the first result that happens to work; it returns the best one it found.

The helper doing the actual encoding:

function canvasToBlob(canvas, type, quality) {
  return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
}

async function encodeImage(bmp, scale, quality, type) {
  const c = document.createElement('canvas');
  c.width  = Math.max(1, Math.round(bmp.width  * scale));
  c.height = Math.max(1, Math.round(bmp.height * scale));
  const ctx = c.getContext('2d');
  if (type === 'image/jpeg') { ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, c.width, c.height); }
  ctx.imageSmoothingQuality = 'high';
  ctx.drawImage(bmp, 0, 0, c.width, c.height);
  const blob = await canvasToBlob(c, type, quality);
  c.width = c.height = 0;
  return blob;
}

That white rectangle is not decoration. Canvas starts transparent, and transparent pixels become black when you encode to JPEG, which has no alpha channel. Drop a PNG with a transparent background into a naive compressor and you get your subject on a black square. Filling white first costs one operation and removes an entire category of bug report.

The c.width = c.height = 0 at the end matters too. You're allocating a full-resolution canvas seven times in a row; on a 48-megapixel phone photo that's a lot of memory held by an element nothing references yet. Zeroing the dimensions releases the backing buffer immediately instead of waiting for the garbage collector to notice.

When quality alone isn't enough

Binary search solves the easy case. It doesn't solve 4 MB to 50 KB, because even quality 0.25 at full resolution won't get there. At some point you have to make the image physically smaller.

The order matters, and it's worth being deliberate about it. Reducing JPEG quality degrades a photograph more gracefully than shrinking it does — you lose fine detail and gain some mushiness, but the picture stays legible. Shrinking throws away pixels permanently. So you exhaust quality at each resolution before giving up resolution:

async function imageToTarget(bmp, target, type, progress) {
  let best = null;
  for (const scale of [1, 0.85, 0.7, 0.55, 0.45, 0.35, 0.25, 0.18, 0.12]) {
    progress?.(`Trying ${Math.round(bmp.width * scale)} px…`);

    let lo = 0.25, hi = 0.95, found = null;
    for (let i = 0; i < 7; i++) {
      const q = (lo + hi) / 2;
      const blob = await encodeImage(bmp, scale, q, type);
      if (blob.size <= target) { found = blob; lo = q; } else hi = q;
    }
    if (found) return { blob: found, reached: true };

    const low = await encodeImage(bmp, scale, 0.25, type);
    if (!best || low.size < best.size) best = low;
    if (low.size <= target) return { blob: low, reached: true };
  }
  return { blob: best, reached: false };
}

Two details in there are worth pulling out.

The extra low encode after a failed search looks redundant — the binary search already tried qualities near 0.25. It didn't, quite. The search probes the midpoints of a shrinking interval, so when every probe comes back too large, the lowest quality it actually tried is somewhere above 0.25. That one extra encode at the floor sometimes succeeds where the search didn't, and it saves a whole scale step.

The other is reached. The function returns the smallest thing it managed to produce even when it fails, along with a flag saying it failed. That lets the UI say "we got it to 68 KB, which is as small as this image goes" instead of throwing. A user who's been told the honest number can crop the photo and try again. A user who got an exception has no idea what to do.

Worst case is nine scales × eight encodes, but in practice it exits in the first or second scale, because most files aren't trying to shrink by a factor of eighty.

Why do this client-side at all

The images going through this are passport photos, signatures, bank statements and immigration paperwork. The whole category of "resize your exam photo" sites is uploading those to a server, and almost none of them say what happens to the file afterwards.

Canvas encoding runs entirely in the page. Nothing is sent anywhere, which you can confirm in the network tab in about five seconds. For this particular class of file, that's not a nice-to-have.

The practical cost is that you're limited to what the browser can encode — JPEG, PNG, WebP — and canvas.toBlob quality is only honoured for lossy formats. PNG ignores the quality argument entirely, which is why the code converts to JPEG when it's chasing a size target.

The limits, honestly

Decoding is the memory ceiling, not encoding. createImageBitmap on a 48-megapixel photo allocates around 190 MB of uncompressed pixels before you've encoded anything. On older phones that's where it falls over.

HEIC needs help. Safari decodes it natively; nothing else does. Everywhere else you need a decoder library before you have a bitmap to work with.

Cropping beats compressing. This is the thing worth telling users, because it's counterintuitive and it dominates everything the algorithm does. Half the pixels at the same file size means twice the detail per pixel. A tightly cropped passport photo at 50 KB looks fine; a full-body shot squeezed into the same 50 KB looks like a JPEG from 1998. No amount of search strategy fixes framing.

The code above is what runs in PDFRoll's image compressor — free, no account, nothing uploaded. If you want to see the target-size path in action, the 50 KB ICAI exam photo page preloads the constraint.

If you've found a better strategy than quality-then-scale, I'd like to hear it. I suspect there's a smarter first guess than 0.6 available from the image's entropy, but I haven't found one that beats just running the search.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.