I'm the developer of MakeQuickWork, a small site of free browser tools. This is how its Compress Image tool hits a file-size target, and the bug that made the first version miss it by two thirds.
Plenty of upload forms say "photo must be under 100 KB". So besides the usual Light / Recommended / Strong levels, the tool has a Target size mode: type a number of KB and get the best-looking file that fits.
The first version worked in the sense that every output was under 100 KB. On my 3200×2400 test photo it produced a 36 KB file at quality 31%. Under the limit, technically. Also a third of the budget, and visibly worse than it needed to be.
The setup
Everything runs in the tab. Encoders are WebAssembly builds via jSquash: MozJPEG for JPG, libwebp for WebP, OxiPNG for PNG. None of them ship with the page. The tool page loads roughly 10 KB of JavaScript, and each encoder is fetched the first time you actually compress something in that format. Files are never uploaded, which you can check in the Network tab: https://makequickwork.com/image/compress-image/
Quality mode is straightforward: decode to a canvas, encode at 85%, 75% or 60%. Target mode is a search problem: find the highest quality q where encode(q).size <= target.
Bug: searching with a stand-in encoder
MozJPEG through WASM isn't instant on a 7.7-megapixel image, and a search needs several encodes. The browser's canvas.toBlob(..., "image/jpeg", q) is much faster. So version one did the search with canvas, found the quality that landed just under 100 KB, then wrote the final file with MozJPEG at that same quality.
That assumes "quality 31%" means the same thing in both encoders. It doesn't. MozJPEG is built to squeeze harder than a typical encoder, and at low quality the gap gets large. Canvas needed 31% to fit in 100 KB. MozJPEG at 31% came out at 36 KB.
The tempting fix is a correction factor. I didn't trust one: the gap between encoders depends on the image content, so any constant is wrong for some photo. The fix that's actually correct is boring. Search with the same encoder that writes the file.
// Search with the same encoder that writes the file: a faster stand-in (the browser's JPG encoder)
// compresses very differently and would pick a much lower quality than needed.
const enc = (c: HTMLCanvasElement, q: number) => encode(c, to, { quality: q, optimize: true });
Making the real encoder affordable
That made each search step slower, so I needed fewer steps. Plain bisection on q between 0.30 and 0.92 takes five or six encodes to get close. But file size isn't random in quality: it grows smoothly and roughly exponentially. So I interpolate on log(size) between the two ends:
const top = await enc(c, 0.92);
if (top.size <= target) best = { blob: top, q: 0.92 };
else {
const floor = await enc(c, 0.3);
if (floor.size > target) continue; // too big even at low quality: try a smaller size
best = { blob: floor, q: 0.3 };
let lo = { q: 0.3, size: floor.size }, hi = { q: 0.92, size: top.size };
for (let i = 0; i < 4 && best.blob.size < target * 0.92 && hi.q - lo.q > 0.03; i++) {
const t = (Math.log(target * 0.97) - Math.log(lo.size)) / (Math.log(hi.size) - Math.log(lo.size));
const q = Math.min(hi.q - 0.02, Math.max(lo.q + 0.02, lo.q + (hi.q - lo.q) * t));
const blob = await enc(c, q);
if (blob.size <= target) (best = { blob, q }), (lo = { q, size: blob.size });
else hi = { q, size: blob.size };
}
}
Details that matter:
- It aims at 97% of the target, not 100%, so the guess lands under more often than over.
- It stops as soon as the best result is within 8% of the target. 92 KB vs 97 KB isn't visible; another encode is.
- The
±0.02clamp keeps a bad guess from collapsing onto an endpoint. -
bestonly ever holds a blob that fit, so the result can never be bigger than the target.
Usually that's two or three encodes after the endpoints, which roughly halved the time on a large photo compared with bisecting. It still takes a few seconds on a big image, because it really does encode several times. Quality mode is a single encode.
Same photo, same 100 KB target, after the fix: 96.1 KB at quality 56%.
When quality isn't enough
If even quality 30% doesn't fit, the image is too big in pixels for the budget. Only then does it shrink dimensions, stepping down through scales (0.85, 0.7, 0.55 and so on) and re-running the search at each. Quality first, pixels last, because most people uploading a photo to a form care more about it not looking smeared than about its exact resolution. If the image would have to drop below 48 px on its short side, it gives up and says so.
Other rules
- PNG can't target a size. PNG is lossless, so there's no quality knob to turn. In target mode a PNG is saved as JPG, or as WebP if it has transparency, and the result row says which.
- Never return a bigger file. In quality mode, if re-encoding in the same format produces something larger (common with already-optimised JPGs), the original is kept and labelled "already well compressed".
- PNG colour reduction is on by default: UPNG.js quantises to at most 256 colours, then OxiPNG repacks. That's a big win for screenshots and icons, but it can band on photos and gradients. Off means lossless repacking only.
- 1 KB = 1024 bytes, because when a form says 100 KB I'd rather err on the strict side.
- EXIF is removed, since the image is re-encoded from a canvas. Rotation is applied first.
In Recommended mode, on my test photos: the 2.9 MB photo went to 258.6 KB (91% smaller), and a 123.0 KB phone-style photo to 53.1 KB.
Checking the "no upload" part
The site is static (Astro on Cloudflare Pages), so there's no endpoint to receive a file. On top of that, an automated test processes files with each tool and fails if there's any POST or any third-party request other than Google Analytics, or if an analytics event contains a file name. GA4 is there for anonymous page and "tool used" counts. You need a connection to load the page and the encoders.
The lesson I keep relearning: a fast proxy for the real computation is fine for a progress bar, not for a decision. If you've done size targeting differently (a model of size vs quality per image, maybe), I'd like to hear about it.
Try it: https://makequickwork.com/image/compress-image/ or with 100 KB preset: https://makequickwork.com/image/compress-image-to-100kb/




Top comments (0)