Indian government exam portals have a rule that has quietly shaped a lot of my code: your photo must be under 50KB. Not "small". Not "optimised". Under 50KB, or the upload is rejected.

Every free tool I found for this wanted me to upload the photo to a server, wait in a queue, and create an account. For a file I just wanted to shrink. So I wrote it myself, in the browser.

This post is about the actual technique — hitting an exact byte target with canvas — and, honestly, about where my implementation still falls short.


The naive version, and why it fails

The obvious approach:

canvas.toBlob(blob => download(blob), 'image/jpeg', 0.7);

Enter fullscreen mode Exit fullscreen mode

Pick a quality, hope for the best. The problem is that JPEG quality has no predictable relationship to output size. Quality 0.7 on a flat, low-detail portrait might land at 18KB. The same 0.7 on a noisy, high-detail photo lands at 210KB. You cannot compute the quality you need — the encoder decides, and it depends entirely on image content.

So you can't calculate it. You have to search for it.

Binary search on quality

toBlob is cheap enough to call repeatedly, and quality is monotonic — higher quality never produces a smaller file. That's exactly the setup binary search wants.

function encode(canvas, quality) {
  return new Promise(resolve =>
    canvas.toBlob(resolve, 'image/jpeg', quality)
  );
}

async function compressToTarget(canvas, targetBytes) {
  let lo = 0.05, hi = 0.95, best = null;

  for (let i = 0; i < 8; i++) {
    const mid = (lo + hi) / 2;
    const blob = await encode(canvas, mid);

    if (blob.size <= targetBytes) {
      best = blob;   // fits — remember it, try for better quality
      lo = mid;
    } else {
      hi = mid;      // too big — back off
    }
  }
  return best;
}

Enter fullscreen mode Exit fullscreen mode

Eight iterations over the range 0.05–0.95 narrows quality to about ±0.002, far finer than anyone can see. Each iteration is one encode; on a typical phone photo the whole loop runs in well under a second.

Two details that matter more than they look:

Keep the best result, not the last one. You want the largest file that still fits. The search can easily end on an iteration that overshot, so store the blob every time it fits rather than returning whatever the final iteration produced.

Eight is a deliberate number. Each iteration is a full JPEG encode. Sixteen would double the cost to buy precision nobody can perceive. Eight is where the curve flattens.

What this approach does not solve

I want to be straight about the limits, because I hit them and haven't fixed them all.

There is a floor, and quality can't get under it. A 4000×3000 photo cannot reach 20KB at any JPEG quality. Even at 0.05 you're encoding 12 million pixels, and the file floor sits above the target. The fix is to stop turning the quality dial and start removing pixels — run the same binary search at 80% resolution, then 64%, until something fits. My current implementation doesn't do this outer loop; for extreme targets on very large images it just reports the target as unreachable. It's the next thing on the list.

Canvas downscaling in one drawImage call is bad. When you do add the resolution step, resist the obvious version:

ctx.drawImage(img, 0, 0, w * 0.3, h * 0.3);  // aliased, crunchy

Enter fullscreen mode Exit fullscreen mode

Canvas doesn't filter properly across a large single-step reduction. Halving repeatedly averages 4 pixels into 1 each time — a cheap box filter — and chaining those gets much closer to real downsampling.

EXIF orientation will bite you. Phone cameras often store the photo unrotated with a tag saying "display this sideways". drawImage ignores the tag, so output can come out rotated 90°. Reading the orientation and applying the transform yourself is the fix.

PNG ignores the quality argument entirely. toBlob(cb, 'image/png', 0.5) gives you the same bytes as 0.9 — PNG is lossless and the parameter does nothing. Any target-size search has to encode as JPEG. Worth saying explicitly in the UI, because "why is my PNG still 2MB" is a reasonable question.

Encoding blocks the main thread. Eight sequential encodes is a visibly frozen tab on a mid-range phone. A Worker with OffscreenCanvas is the right answer. Mine still runs on the main thread.

Why bother keeping it client-side

The practical reason: it's faster. No upload, no queue, no download. On a 5MB photo over a slow connection the round trip dominates everything — the compression itself is milliseconds.

The reason I care about more: the file never leaves the device. There's no upload endpoint, so there's nothing on my side to store, log, or leak. For a passport photo or a bank statement, that's not a nice-to-have.

It also makes the economics work. A tool that runs entirely on the user's machine costs approximately nothing to serve, which is how it stays free with no "sign up to download" wall.

The same pattern carries beyond images — PDF work uses pdf.js for rendering and pdf-lib for manipulation, and merge, split and page extraction all happen locally.

Where this runs

I've been collecting these into Toolzo. The compress-to-exact-KB tool is the code above, and the image compressor and PDF compressor are built on the same idea. Free, no account, nothing uploaded.


If you've built something similar: did you go with the resolution-reduction outer loop, or find a smarter way to predict the achievable floor before searching? Estimating it up front would save most of the wasted encodes and I haven't found a reliable heuristic.