DEV Community

qinjie xu
qinjie xu

Posted on

Compressing images in the browser with Canvas (and hitting an exact KB target)

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 });
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 maxQ first and bailing out early avoids seven pointless encodes.
  • The result is best-effort, not guaranteed. If the image is so detailed that even minQ exceeds 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");
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 + OffscreenCanvas replaces 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)