DEV Community

Jaydip Patel
Jaydip Patel

Posted on

How to compress an image to an exact file size in the browser

Upload forms don't ask for "quality 70". They ask for "under 50 KB". Exam portals, visa applications and job sites all do it, and a phone photo is 3–5 MB, so the gap is a factor of fifty or more.

A quality slider doesn't answer that question. There's no quality setting that means 50 KB, because the same setting gives a 30 KB file for a plain portrait and a 300 KB file for a busy street scene. You have to search for it.

This is how I built exact-size compression in ImgKit, which runs entirely in the browser. Nothing here needs a server.

Step 1: binary-search the quality

For JPEG and WebP, file size goes up as quality goes up. It isn't perfectly smooth, but it's close enough that a binary search works: try the middle, keep whichever half still fits.

async function fitQuality(canvas, type, maxBytes) {
  const encode = (q) =>
    new Promise((resolve) => canvas.toBlob(resolve, type, q));

  let lo = 0.1, hi = 0.95;
  let best = await encode(lo);
  if (best.size > maxBytes) return null; // quality alone can't get there

  const top = await encode(hi);
  if (top.size <= maxBytes) return top;  // already fits at high quality

  for (let i = 0; i < 7; i++) {
    const mid = (lo + hi) / 2;
    const blob = await encode(mid);
    if (blob.size <= maxBytes) { best = blob; lo = mid; }
    else hi = mid;
  }
  return best;
}
Enter fullscreen mode Exit fullscreen mode

Seven steps narrow a 0.1–0.95 range to about 1% of quality, which is well past the point anyone can see a difference. Two things are worth getting right:

  • Keep the best blob that fit, not the last one tried. The final midpoint is often just over the limit.
  • Check both ends first. If the lowest quality is too big, searching is pointless. If the highest already fits, there's no reason to throw quality away.

Step 2: when quality isn't enough, shrink the image

A 12-megapixel photo at quality 10% can still be 400 KB. At that point the only lever left is the number of pixels.

File size is roughly proportional to pixel count, and pixel count goes with the square of the side length. So if the smallest encode is 4× too big, shrink each side by about √(1/4) = ½:

const factor = Math.sqrt(maxBytes / smallest.size) * 0.92;
width = Math.round(width * factor);
height = Math.round(height * factor);
Enter fullscreen mode Exit fullscreen mode

The 0.92 is a safety margin: the relationship isn't exact, and undershooting slightly means one resize instead of two. Then run the quality search again at the new size. Cap the number of attempts and stop at a sensible minimum side (I use 64 px), so an impossible target fails with a clear message instead of producing a thumbnail.

Step 3: search with the fast encoder, finish with the good one

The browser's built-in encoder (canvas.toBlob) is fast but not very good. MozJPEG, compiled to WebAssembly, typically fits the same picture into 10–30% fewer bytes. It's also 15–25× slower. A search that has to resize a few times can run dozens of encodes, so doing all of it with MozJPEG would take several seconds per photo.

So the search runs with the fast encoder, and only the final file uses the good one. That creates a subtle problem. Suppose the fast encoder found that quality 25% fits 50 KB. MozJPEG at 25% might come out at 38 KB, which leaves 12 KB of budget unspent. The user gets a worse-looking photo than necessary.

The fix is a short second search, with the real encoder, upward from where the first one stopped:

let best = await mozjpeg(canvas, bestQuality); // same quality, fewer bytes
let lo = bestQuality, hi = 0.95;
for (let i = 0; i < 4; i++) {
  const mid = (lo + hi) / 2;
  const candidate = await mozjpeg(canvas, mid);
  if (candidate.size <= maxBytes) { best = candidate; lo = mid; }
  else hi = mid;
}
Enter fullscreen mode Exit fullscreen mode

Four extra encodes costs about 0.3 seconds and spends the leftover budget on quality. The file still fits, and it looks as good as the limit allows. I use jSquash for the WebAssembly builds of MozJPEG and OxiPNG. They're the same encoders Squoosh uses.

The details that bite

  • Transparency turns black. JPEG has no alpha channel, so a transparent PNG drawn onto a canvas and saved as JPEG gets black where it was clear. Fill the canvas with white before drawing.
  • iOS Safari has a canvas size limit of roughly 16.7 megapixels. A big photo just fails to get a context. Check for that and start from a scaled-down size instead.
  • The original may already fit. If the file is under the target and the user didn't ask for a format change, return it untouched. Re-encoding a small JPEG can make it bigger and always costs a little quality.
  • HEIC needs decoding first. Browsers other than Safari can't draw HEIC, so iPhone photos need a decode step before any of this starts. I use heic2any, a JavaScript build of libheif.

Result

The file lands just under the limit rather than well below it, so none of the size budget is wasted. Everything happens in the tab, so it works offline and the photo never leaves the device.

If you want to see it working, it's the compress to 100 KB tool on ImgKit. I built it, so treat the plug accordingly. The approach works for any encoder that takes a quality setting, and the whole thing is about 200 lines.

Top comments (0)