Every developer has met this form validation message: "Photo must be under 200KB." "Signature: max 50KB." The usual fix is to send the user's photo to the server, resize it there, and send it back. It works — but it also means a stranger's ID photo, signature, or profile picture just travelled to your infrastructure and sat in a temp folder so you could shrink it.
There is a better default: do the whole job in the browser. The file never leaves the device, and you still hit the exact size limit. Here's how the technique works, and the edge cases that will bite you if you skip them.
The problem: a quality slider is not a file-size control
The browser gives you one knob for JPEG output, canvas.toBlob()'s quality parameter (0 to 1). The naive approach is "keep lowering quality until it fits." Two things make that painful:
- Size does not move linearly with quality. Dropping from 0.9 to 0.8 can save hundreds of kilobytes; dropping from 0.4 to 0.3 might save almost nothing. Fixed steps waste encodes or overshoot.
- Some targets are unreachable by quality alone. A 4000 x 3000 phone photo will not fit into 20KB at any quality. At some point you must reduce the pixel count.
So the algorithm needs two loops: search quality first, shrink dimensions only when quality can't do the job.
Step 1: Search quality, keep the best fit
Binary search beats stepping. Seven iterations narrow quality to well under 0.01 — finer than any visible difference — and you keep the largest file that still fits, not the first one that fits. That matters when a human reviews the photo: every byte you're allowed to keep is detail in someone's face.
async function encode(canvas, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("encode failed"))),
"image/jpeg",
quality
);
});
}
async function fitQuality(canvas, maxBytes) {
const MIN_Q = 0.1, MAX_Q = 0.95;
// If even the lowest quality is too big, quality alone can't fix it.
const smallest = await encode(canvas, MIN_Q);
if (smallest.size > maxBytes) return null;
let lo = MIN_Q, hi = MAX_Q, best = smallest;
for (let i = 0; i < 7; i++) {
const q = (lo + hi) / 2;
const blob = await encode(canvas, q);
if (blob.size <= maxBytes) { best = blob; lo = q; }
else { hi = q; }
}
return best;
}
One detail people get wrong: is 1KB 1000 or 1024 bytes? Forms rarely say. Using 1000 passes either way, so treat the target as targetKB * 1000.
Step 2: Shrink dimensions only when needed
When fitQuality returns null, estimate the scale factor from how far off you are. JPEG size grows roughly with pixel count, and pixel count grows with the square of the scale factor. If the smallest encode is 4x too big, scaling each side by sqrt(1/4) = 0.5 is a good first guess:
async function compressToSize(file, targetKB) {
const maxBytes = targetKB * 1000;
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
let scale = 1;
for (let round = 0; round < 6; round++) {
const canvas = drawScaled(bitmap, scale);
const fitted = await fitQuality(canvas, maxBytes);
if (fitted) return fitted;
const smallest = await encode(canvas, 0.1);
const ratio = Math.sqrt(maxBytes / smallest.size);
scale *= Math.max(0.5, ratio * 0.95); // safety margin, max half per round
}
throw new Error(`Cannot reach ${targetKB}KB without destroying the image`);
}
In practice this lands in one or two rounds, because the square-root estimate is close.
The pitfalls (each one cost me a bug report)
-
EXIF orientation. Phone photos are often stored sideways with an EXIF flag.
createImageBitmap(file, { imageOrientation: "from-image" })respects it; without that option, uploads come out rotated. - Transparency turns black. JPEG has no alpha channel. Fill the canvas white before drawing, or a transparent signature PNG becomes a black rectangle.
- Files that already fit. If someone uploads a 15KB JPG and asks for 20KB, re-encoding only makes it worse. Return the original — but check the format still matches, since a 15KB PNG fails a "JPG only" form.
- Huge inputs on phones. A 48MP photo decodes into a big pixel buffer, and mobile browsers have canvas size limits. Cap the long side (e.g. 2000px) before the search starts.
- No magic quality number. Chrome and Safari use different JPEG encoders, so the same quality value yields different sizes. Because the search runs on the user's own encoder, it adapts automatically. Never hard-code a "quality 0.62 works for 100KB" rule.
-
Don't block the UI. Seven to fifteen encodes can freeze a slow phone. Move the work to a Web Worker with
OffscreenCanvaswhere supported.
The ops checklist: client-side is not a trust boundary
Doing this in the browser is a privacy and UX win, not a security control. Your server must still:
- Re-check the size server-side. A crafted request can claim any size.
- Validate MIME type by content, not extension. Sniff the magic bytes; reject anything outside your allowlist.
-
Enforce a hard cap at the reverse proxy (e.g.
client_max_body_sizein nginx) so oversized bodies never reach your app. - Delete originals. If any flow still uploads an original, make sure the resized derivative is the only thing that persists.
One side benefit worth knowing: re-encoding through canvas drops EXIF metadata, including GPS tags, because canvas works with raw pixels. That is a property of the technique, not a reason to skip server-side validation — but it is one more reason to prefer client-side processing for sensitive uploads like ID photos and signatures.
Try it without writing code
If you just want to see the approach in action, TargetKB's free browser image compressor does exactly this: type a target in KB or MB and get a file that fits, with the image processed on your own device. No sign-up, no upload.
What upload limits have you run into? The weird ones — "signature must be under 20KB, JPG only, 140x60 pixels" — are always the most fun to break. Tell me in the comments.
Zhang Qiang writes practical IT guides at TargetKB — cybersecurity and ops troubleshooting, plus handy browser tools that keep your data on your own device.
Top comments (0)