Every "compress image" feature I've shipped has followed the same arc: upload the file, compress it on the server, send it back. It works, but it means you're paying for bandwidth twice, storing other people's photos, and adding a round trip to something that should feel instant.
Modern browsers can do the whole job locally. Here's what I learned building it, including the part that took the longest — hitting a specific file size instead of just "smaller".
The baseline: toBlob with a quality knob
The core is two lines:
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0);
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.8 });
Three things worth knowing:
Use createImageBitmap over new Image(). It decodes off the main thread, and by default it applies EXIF orientation (imageOrientation: "from-image"), so phone photos don't come out sideways. With the Image + URL.createObjectURL approach you have to handle orientation yourself.
OffscreenCanvas works on the main thread too. You don't need a worker to use it, but you do need one if you don't want the UI to stutter on large images.
PNG ignores quality. That's not a bug — PNG is lossless, so the quality parameter simply does nothing. If a user asks you to "compress this PNG to 200 KB", your only real levers are reducing dimensions or switching to WebP (or JPEG if there's no transparency).
The hard part: a target file size
Quality is not linear in bytes. Quality 0.8 might produce 180 KB on one photo and 40 KB on another, because it depends entirely on the content. So you can't compute the right quality — you have to search for it.
Binary search converges in about 7 iterations, which is fast enough to feel instant:
async function compressToTarget(bitmap, targetBytes, { minQ = 0.3, maxQ = 0.92 } = {}) {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0);
const render = (q) => canvas.convertToBlob({ type: "image/jpeg", quality: q });
let lo = minQ, hi = maxQ, best = await render(maxQ);
if (best.size <= targetBytes) return best;
for (let i = 0; i < 7 && hi - lo > 0.01; i++) {
const mid = (lo + hi) / 2;
const blob = await render(mid);
if (blob.size <= targetBytes) {
best = blob; // keep the best that still fits
lo = mid;
} else {
hi = mid;
}
}
return best;
}
Two details that matter in practice:
-
Start from the top, not the middle. Most images don't need to be compressed at all. Rendering at
maxQfirst and bailing out early avoids seven pointless encodes. -
The result is best-effort, not guaranteed. If the image is so detailed that even
minQexceeds the target, quality alone can't save you — you have to fall back to downscaling. Be honest in the UI about this; "target 100 KB" that quietly produces 340 KB is worse than saying "couldn't reach 100 KB at this width".
When quality isn't enough: shrink the canvas
If the minimum acceptable quality still overshoots, reduce the long edge and try again. A simple approach is to step the width down by 10% and re-run the search, capped at a few attempts:
async function compressWithFallback(bitmap, targetBytes) {
let current = bitmap;
for (let attempt = 0; attempt < 5; attempt++) {
const blob = await compressToTarget(current, targetBytes);
if (blob.size <= targetBytes) return blob;
const scale = 0.9;
current = await createImageBitmap(current, {
resizeWidth: Math.round(current.width * scale),
resizeHeight: Math.round(current.height * scale),
resizeQuality: "high",
});
}
throw new Error("Could not reach target size");
}
Worth noting: resizeQuality: "high" matters. The default is "low", which produces visibly soft results on downscales — the kind of thing that only shows up when someone zooms in.
Batches are where it gets ugly
Single-file compression is easy. Fifty files is a memory problem. A 40-megapixel image is 160 MB as an RGBA buffer, and each OffscreenCanvas you keep alive holds one. Decode everything up front and you'll crash the tab on a laptop.
The fix is boring: process sequentially, and release references as you go.
for (const file of files) {
const bitmap = await createImageBitmap(file);
const blob = await compressToTarget(bitmap, target);
results.push(blob);
bitmap.close(); // free the decoded buffer immediately
}
bitmap.close() is the important line. Without it, decoded bitmaps hang around until GC decides to run, and Chrome is conservative about that.
If you want progress feedback without janking the UI, run the loop inside a Worker with OffscreenCanvas — it's fully supported there, and you can post progress messages back as each file finishes.
Why bother compressing locally at all
Beyond the obvious cost savings, there's a privacy argument that turns out to matter to users more than I expected: if compression happens in the page, the file never touches a server. For screenshots containing client data, medical images, internal dashboards, or anything under an NDA, "we don't upload it" is a feature you can actually promise rather than a line in a policy document.
It's also why I ended up building PicZip this way — no upload, no account, batch handling with a ZIP export, and a target-size field, because "make it smaller" is rarely the actual requirement. The whole thing is a static page; there's no backend to leak anything because there's no backend.
Takeaways
-
createImageBitmap+OffscreenCanvasreplaces most server-side image pipelines for this kind of task. - Binary search on quality converges fast, but always render the high end first and bail early.
- PNG has no quality knob — dimensions or format change are the only real levers.
- Call
bitmap.close()in batch loops or you will run out of memory on large photos. - Tell the user when the target is unreachable instead of silently missing it.
If you're doing this in production, the two things I'd test hardest are EXIF orientation across camera models and the memory ceiling on low-end phones with a full batch queued.
Top comments (0)