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

Building an Image Compressor That Hits an Exact File Size

Every upload form has the same annoying quirk: it rejects on file size, but every compressor gives you a quality slider instead of a size input. So you end up in a loop. Compress, check size, compress again. Adjust qual

Every upload form has the same annoying quirk: it rejects on file size, but every compressor gives you a quality slider instead of a size input.

So you end up in a loop. Compress, check size, compress again. Adjust quality from 80 to 70. Check again. 65. Again.

I wanted to invert that: give the tool the number, let it figure out the quality.

The problem with quality-first

JPEG file size doesn't vary linearly with the quality parameter. Going from q=0.9 to q=0.8 might drop the file by 40%, or by 8%, depending on the image content. That's why the manual loop feels so random.

But the relationship is monotonic — lower quality always means smaller file. That's enough for a binary search.

Binary search over quality

For a fixed image size, find the highest quality whose encoded blob is still under the target:

async function encodeUnderTarget(canvas, mime, targetBytes) {
  // Check the ceiling first: if even q=0.95 fits, done.
  const top = await toBlob(canvas, mime, 0.95);
  if (top.size <= targetBytes) return { blob: top, quality: 0.95 };

  let lo = 0.15, hi = 0.95, best = null;

  // 6 iterations narrows the range to ~1% — plenty for file size.
  for (let i = 0; i < 6; i++) {
    const mid = (lo + hi) / 2;
    const blob = await toBlob(canvas, mime, mid);
    if (blob.size <= targetBytes) {
      best = { blob, quality: mid };
      lo = mid;          // this works, try higher quality
    } else {
      hi = mid;          // too big, try lower
    }
  }
  return best;           // null means even q=0.15 is too big
}

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

Six encodes lands within a few percent below the target, instead of the 20-40% below you get from guessing a quality value.

When quality isn't enough

Some images can't hit the target at any quality — high-ISO photos, heavy noise, complex texture. So there's a second loop: if even q=0.15 overshoots, scale down and retry.

let scale = 1;
for (let attempt = 0; attempt < 6; attempt++) {
  const canvas = drawScaled(img, srcW * scale, srcH * scale);
  const result = await encodeUnderTarget(canvas, mime, targetBytes);
  if (result) return { ...result, scale };

  scale *= 0.7;   // 30% smaller each round
  if (Math.max(srcW, srcH) * scale < 48) break;
}

The 0.7 factor matters. At 0.9 you need many more rounds, and each round costs 6 encodes.

Three things that bit me

1. Transparent PNGs turn black in JPEG

canvas.toBlob(..., 'image/jpeg') composites onto black by default. You have to paint white first:

ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);

2. Huge images make the search unusable

Each toBlob() on a 24MP canvas takes 300-500ms. Six encodes × six scale rounds = 30+ seconds. So downscale anything above ~12MP before searching:

const MAX_PIXELS = 12e6;
if (srcW * srcH > MAX_PIXELS) {
  scale = Math.sqrt(MAX_PIXELS / (srcW * srcH));
}

3. Files already under target shouldn't be re-encoded

If your 20KB file is already under 50KB, re-encoding it loses quality for zero benefit. Pass it through untouched:

if (file.size <= targetBytes) {
  return { blob: file, scale: 1, passthrough: true };
}

Results

Input Target Output Time
1600×1200 photo, 148 KB 50 KB 49 KB ~1.4s
3000×2000 photo, 2.7 MB 50 KB 50 KB ~7s
1200×900 photo, 88 KB 20 KB 20 KB ~0.7s
240×180, 14 KB 50 KB unchanged ~0.1s

What I'd like to know

  • Is binary search the right approach? libjpeg does something similar internally but I wanted pure JS with no wasm payload. Is there a smarter rate-control strategy?
  • WebP vs JPEG: WebP wins 25-35% at equal quality, and it's my default. But Safari's WebP encode path was historically slow — anyone have current numbers?
  • The 12MP cutoff: arbitrary. Below it, search feels instant; above it, it drags. Is there a better heuristic?

The tool is at imgpress-c90.pages.dev — no upload endpoint exists, you can go offline after the page loads and it still works. Source is plain JS, no build step.

📰 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.