Government and exam forms are oddly specific about photos. A typical application portal doesn't say "a small photo, please" — it says the photo must be under 50KB, the signature under 20KB, and the passport photo exactly 413 × 531 pixels. Generic "compress image" tools don't help much here: they hand back whatever size the encoder happens to produce, and if it's 63KB against a 50KB limit, the form rejects it and you start again.
We needed a tool that treats the KB number as a hard target, not a suggestion — and that never uploads the photo anywhere, because people were feeding it ID-style portraits. The result is a single-page tool built on <canvas> and one idea: JPEG file size is monotonic in the quality setting, so you can binary-search it.
Step 1: Decode and draw
The image is decoded with createImageBitmap() where available, falling back to an <img> element with an object URL. It is drawn onto a canvas pre-filled with white:
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0, c.width, c.height);
The white matte matters: the output is always JPEG (the format with a quality dial), so any transparency in a PNG or WebP source has to be flattened onto something — white is what form photos expect. Everything is re-encoded with canvas.toBlob(cb, "image/jpeg", q), where q is the variable we get to search over.
Step 2: Probe at minimum quality — then shrink pixels, not hope
The target in bytes is exact: targetBytes = maxKB * 1024. The first encode at any given size is a probe at the lowest quality, q = 0.05. If even that is too big, no quality setting at this resolution will ever fit — the only way down is fewer pixels:
var minBlob = await toBlobP(canvas, 0.05);
if (minBlob.size > targetBytes && t.type !== "passport") {
if (Math.min(canvas.width, canvas.height) <= 80 || attempt === 13) {
best = { blob: minBlob, /* …accept smallest possible… */ };
break;
}
scale *= 0.8;
continue; // redraw smaller and probe again
}
Each retry multiplies the scale by 0.8 and redraws. Shrinking dimensions before touching quality preserves detail where it counts: a 4000 × 3000 phone photo stepped down a few times usually fits a 50KB budget at a respectable quality, while the same photo at full size would need a mushy q near the floor. The loop stops shrinking when the smallest side reaches 80 px; below that, a form photo isn't recognisable anyway.
Step 3: Binary-search the quality
Once the minimum-quality encode fits, the search walks quality up to land as close under the cap as possible:
var lo = 0.05, hi = 0.92, bestBlob = minBlob;
for (var i = 0; i < 7; i++) {
var mid = (lo + hi) / 2;
var b = await toBlobP(canvas, mid);
if (b.size <= targetBytes) { bestBlob = b; lo = mid; }
else { hi = mid; }
}
Every candidate that fits becomes the new best and moves the lower bound up; every overshoot moves the ceiling down. Seven iterations halve the 0.05–0.92 interval to under 0.01 of quality resolution — more iterations buy almost nothing and each one costs a full encode. The cap of 0.92 is deliberate: past that, JPEG sizes climb steeply for gains nobody can see on a form photo. Because every comparison is against the real blob.size, the result isn't an estimate — it's a measured file that is actually under the limit.
The presets are just targets fed into the same engine: max 50KB, 20KB, 100KB, or 200KB, a 20KB ceiling for signatures, and a custom value clamped between 5KB and 5000KB.
The passport exception: pixels are the requirement
Passport mode inverts the priorities. The requirement is exact dimensions — 413 × 531 px (35 × 45 mm at 300 DPI) — so the canvas is fixed at that size and the source is cover-cropped into it: scaled by Math.max(t.w / sw, t.h / sh) and centred, cropping the overflow. The dimension step-down is skipped entirely; only the quality search runs, against a 200KB ceiling that a 413 × 531 JPEG effectively always meets. Getting the crop or the aspect wrong fails the application, so this mode refuses to be clever about size.
Edge cases, honestly
- Already under the limit? The tool still re-encodes: the search walks quality up toward 0.92 and returns the best version that fits. The output is always a fresh JPEG, never the untouched original.
- An impossible target. If a huge, noisy photo still exceeds the cap at minimum quality and minimum size, the tool returns the smallest file it could make and says so — "Closest possible is X" — instead of pretending the target was met.
- EXIF orientation is not handled explicitly. Decoding is delegated to the browser, and the re-encode drops the original metadata. That's a real limitation to test against your target browsers, not a detail to hand-wave.
-
Encoding can fail. A
nullblob fromtoBlobsurfaces as a plain "please try another photo" message rather than a silent dead button.
Privacy is a side effect of the architecture
There is no upload endpoint to trust or distrust: the code contains no network calls at all. The file is decoded locally, the preview and the download link are both blob object URLs, and the previous result's URL is revoked when a new one is generated. "Your photo never leaves your device" is printed on the page — and for once it's not a policy statement, it's a description of the code path.
If you want to see the search run against a real photo, the live tool is here: Compress Image to 50KB Online Free. The whole engine is the snippet above plus drawing code — small enough that the interesting part isn't the size of the code, it's treating a bureaucratic KB limit as a search problem instead of a slider.
Top comments (0)