DEV Community

hao jia
hao jia

Posted on

Compress-to-size in the browser: probe the quality floor before you bisect

Our internal file service rejects images above a fixed size, and the product side asked for the upload component to "just compress it until it fits". Sending the file to an external compression API was off the table for compliance reasons, so it had to happen in the browser with canvas.toBlob. Before my team picked an algorithm, I measured three common ones on a 16 GB Apple M4 running an open-source Chromium 149 build. The one thing I'd now insist on is small: encode once at your quality floor before you start the search.

Three ways to hit a target size

A is a float binary search over quality 0.30 to 1.00 that encodes 0.30 first to probe the floor. B is an integer binary search over 30 to 100 with no probe. C starts at 0.92 and steps down by 0.05 until the result fits. "Fill" is result bytes divided by the target. Higher fill means less quality given away for nothing.

Case A: float, probe floor B: integer C: step down
Real photo, JPEG, 2 MB 8 encodes, 96.7% 6 encodes, 96.7% 1 encode, 64.4%
Real photo, WebP, 2 MB 8 encodes, 94.9% 7 encodes, 93.0% 1 encode, 48.9%
Real photo, JPEG, 200 KB (impossible) fails after 1 encode fails after 6 fails after 13

C looks fast because it stops at the first result that fits. On the 2 MB JPEG case it used only 64.4% of the budget, so the user gets a visibly worse image than they had room for, and when the target can't be reached it burns 13 encodes to find out. A and B land on the same JPEG file. Chromium rounds JPEG quality to whole percent (0.951 still encodes as 95, 0.955 as 96), so A's extra float steps can't change anything, and B does the same job in 1 to 2 fewer encodes. The last row is the one that decided it for me. At quality 0.30 the real photo (a 3840×2160 landscape that ships with the OS) is still 315 KB, so no quality value will ever get it under 200 KB. A knows that after one encode. B needs six.

Repro page: the floor-probe run on the real photo at a 200 KB JPEG target, one encode, reported as not reachable by quality

This screenshot is one run of the floor probe. Look for the single encode at the floor. Its byte count is already over the target, so the page says quality alone can't get there and the code moves on to resizing. Nothing else gets encoded.

I used ImgIng (https://imging.ai/) on that M4 Mac with Chromium 149 to see what a production compressor does instead. It doesn't take a target size for images. It picks a starting quality from a table by content type (photo, smooth, texture, graphics/text; WebP 75 and JPG 80 for photos), and a comment in its page source says the table was calibrated with SSIM measurements and side-by-side viewing. That made me wonder whether the top end of our search should come from a table like that rather than from the top of the scale. I haven't tested it, and I'm not changing the component until I have numbers for our own image mix.

What we're shipping

The version below does both things. It probes the floor first, then searches whole numbers. It also caps the search at 99, because at quality 1.0 Chromium's WebP encoder switches to lossless and a loose limit would let the search walk right up to it.

async function fitToBytes(canvas, mime, maxBytes, { floorQ = 50, capQ = 99 } = {}) {
  const encodeAt = (q) => new Promise((ok) => canvas.toBlob(ok, mime, q / 100));
  const floorBlob = await encodeAt(floorQ);
  if (floorBlob.size > maxBytes) return { fits: false, floorBytes: floorBlob.size };
  let lo = floorQ + 1, hi = capQ, best = { q: floorQ, blob: floorBlob };
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    const blob = await encodeAt(mid);
    if (blob.size <= maxBytes) { best = { q: mid, blob }; lo = mid + 1; } else hi = mid - 1;
  }
  return { fits: true, ...best };
}
Enter fullscreen mode Exit fullscreen mode

When fits comes back false, the caller scales both sides by Math.sqrt(maxBytes / floorBytes) * 0.97, redraws, and calls it again. On our test images that meant one extra round, and two for the real photo JPEG, where the first estimate still came out slightly over. I ran this exact function against the same images and it reproduced the earlier results byte for byte. For example, the 24 MP test image (a photo-like texture I generated, not a real photo) failed the probe at 1,355,571 bytes and finished after one resize at quality 51 and 195,424 bytes. The floor of 50 and the 0.97 margin are our choices, and I wouldn't defend them as universal.

If your upload path has a compress-to-size loop, check what it does with a target it can't reach. Count the encodes before it gives up. If that count is more than one, add the floor probe.

Top comments (0)