DEV Community

Cover image for Compress Any Image to Exactly 100KB: A Binary-Search Trick
bowen Tse
bowen Tse

Posted on Originally published at toolkitloop.blogspot.com

Compress Any Image to Exactly 100KB: A Binary-Search Trick

Every web developer has lived this exact loop:

A form says "upload must be under 100KB". You export your image at quality 80. It's 340KB. You drop to 60. It's 61KB — nice. Or it's 134KB — not nice. You try 55. It's 98KB. Close enough. Ship it.

Ten minutes of blind guessing to satisfy one number. And the number you were actually asked about — the file size — was never under your control. You were tuning a proxy (the quality slider) and hoping the size landed somewhere acceptable.

There's a better primitive, and it works every single time.

Why the quality slider is the wrong primitive

Here's the uncomfortable truth about the quality slider: the same quality value produces wildly different file sizes on different images. A flat illustration at quality 80 might be 40KB. A noisy smartphone photo at quality 80 might be 900KB. A gradient-heavy hero image, something else entirely.

The quality number controls how aggressively the encoder throws away detail, not how many bytes come out. The mapping from quality to bytes depends entirely on the content of the image — its entropy, its noise, its colors. It's a function you can't evaluate without running the encoder.

So the classic workflow — pick a quality, check the size, adjust, repeat — is just manual search. You are acting as a human binary-search over the quality axis, one export at a time. That's not a skill issue; it's a bad primitive. The tool gave you the wrong input.

The fix is to invert the problem.

Think target-first

Instead of asking "what quality should I use?", ask: "what is my byte budget, and what's the highest quality that fits inside it?"

That's it. The input is a target — 100KB — and the output is the best-looking image that fits. This is exactly what "target-first compression" means, and it's straightforward to implement because the relationship between quality and output size is monotonic: lower quality never produces a bigger file (for lossy encoders). Monotonic function? Then you can binary-search it.

The algorithm:

  1. Draw the image to a canvas, capping the long edge at a sane size (say, 1920px — there's no point compressing a 6000px photo to 100KB, it will look terrible).
  2. Binary-search the quality axis over ~8 iterations of canvas.toBlob, keeping the best blob that fits the budget.
  3. Download the winner.

Eight iterations is a good number: 2^8 = 256 quality steps, finer than any slider you've ever dragged, and it runs in milliseconds because each iteration is a single encode.

Why does binary search work here? Because the encoder is monotonic in quality: raising quality never shrinks the output. That one property turns a guessing game into a solved problem. (A purist will note that monotonicity can wobble at the extreme low end of some encoders — in practice, the "bank the best fit" pattern absorbs that: a wobble might cost you a slightly suboptimal answer, but never an invalid one. You'll always ship a file that fits.)

Here's the whole thing, drop-in ready:

// Binary-search the highest quality whose output fits `targetBytes`.
// canvas: HTMLCanvasElement or OffscreenCanvas, mime: "image/jpeg" or "image/webp".
async function compressToTarget(canvas, mime, targetBytes, iterations = 8) {
  const blobOf = (q) =>
    new Promise((resolve) => canvas.toBlob(resolve, mime, q));

  let lo = 0.0, hi = 1.0;
  let best = await blobOf(0); // lowest quality always fits — worst case fallback
  for (let i = 0; i < iterations; i++) {
    const mid = (lo + hi) / 2;
    const blob = await blobOf(mid);
    if (!blob) break; // encoder refused this setting
    if (blob.size <= targetBytes) {
      best = blob; // fits — bank it, aim higher
      lo = mid;
    } else {
      hi = mid; // too big — go lower
    }
  }
  return best;
}

// Usage:
const blob = await compressToTarget(myCanvas, "image/jpeg", 100 * 1024);
console.log(`Final size: ${(blob.size / 1024).toFixed(1)} KB`);
Enter fullscreen mode Exit fullscreen mode

A few notes for the copy-pasters:

  • canvas.toBlob takes quality as a float from 0 to 1 — not 0–100.
  • This trick applies to lossy formats (JPEG, WebP). PNG is lossless: the quality parameter is ignored, so the search collapses immediately and you get one answer.
  • If even quality 0 exceeds your budget, your image is too big dimensionally — halve the canvas resolution and search again. Bytes follow pixels roughly linearly, so one resize usually does it.
  • best always holds a valid answer: you'll never get undefined back, even if nothing at reasonable quality fits.

That's the entire technique. No libraries, no server, no guessing.

Don't want to write code? I built this for you

Full disclosure: I built this free tool — Compress Image to 100KB.

It does exactly what the code above does, but wrapped in a UI: type in your target size, drop in (or paste from clipboard) a JPG, PNG, or WebP, and the browser searches for the highest quality that fits your budget. The result card always shows the actual output size, so there's no guessing loop. Everything happens locally in your browser — your images are never uploaded anywhere — and it's free with no signup.

What I like most about having it as a tool rather than a snippet: it removes the "is this good enough?" hesitation. When the byte budget is enforced automatically, you stop compressing defensively — you don't nuke an image to quality 55 "just to be safe" when quality 72 would have fit fine.

It's the tool I wish every upload form had next to its "max 100KB" requirement.

The real question

The technique transfers everywhere: thumbnails for a listing page, avatars with a strict size cap, email attachments that must stay under some corporate limit, or just keeping a portfolio page fast. Once you think in byte budgets instead of quality sliders, you stop exporting-and-checking entirely.

So here's my question for you: what byte target do you actually ship images at? Is 100KB still the magic number because of form limits, or have you moved to something else — 200KB? 500KB? And do you compress at upload time, at build time, or with a CDN? Drop your number and your setup in the comments — I'm curious how the real world differs from my 100KB tunnel vision.

Top comments (0)