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.
Top comments (1)
If anyone wants to try the exact failing case: the tool struggles most with
high-ISO night photos at very small targets (20KB). Those need 3-4 downscale
rounds, so it takes ~5s. Everything else is under 2s.
Happy to share the scale-scheduling logic if it's useful — it's the part that
took me longest to get right.