Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Compress an image to an exact size limit in JavaScript (binary search on quality)

Disclosure: an AI agent wrote this article for Hexloom Labs; no human wrote or edited it. Written with AI assistance and reviewed by Hexloom Labs. The code below was run in headless Chromium 151 before posting, and the

Disclosure: an AI agent wrote this article for Hexloom Labs; no human wrote or edited it.

Written with AI assistance and reviewed by Hexloom Labs. The code below was run in headless Chromium 151 before posting, and the numbers come from that run.

Upload forms love hard limits: "photo must be under 100 KB". The quality slider in most editors doesn't help, because it never says how big the file will be. You nudge it, save, check the size, and repeat.

A browser can run that loop for you. canvas.toBlob(callback, type, quality) encodes a JPEG or WebP at a quality from 0 to 1 and hands back a Blob, so you can read blob.size and decide what to try next. File size rises with quality, so instead of trying every step you can halve the range each time. Seven to nine encodes were enough in the runs below.

The function

// Re-encode `bitmap` as JPEG or WebP so the file is at most `maxBytes`.
// Resolves to { blob, quality, width, height, encodes }, or null if it cannot fit.
async function fitToBytes(bitmap, maxBytes, type = "image/jpeg") {
  const canvas = document.createElement("canvas");
  const encode = (q) => new Promise((done) => canvas.toBlob(done, type, q / 100));
  let w = bitmap.width, h = bitmap.height, encodes = 0;

  for (;;) {
    canvas.width = w;
    canvas.height = h;
    const ctx = canvas.getContext("2d");
    if (type === "image/jpeg") {
      ctx.fillStyle = "#fff"; // JPEG has no alpha: transparent pixels would turn black
      ctx.fillRect(0, 0, w, h);
    }
    ctx.drawImage(bitmap, 0, 0, w, h);

    let blob = await encode(10);
    encodes++;
    if (!blob || blob.type !== type) return null; // this browser cannot write the format

    if (blob.size <= maxBytes) {
      // Quality 10 fits. Binary search for the highest quality that still does.
      let best = { blob, quality: 10 }, lo = 10, hi = 96; // hi is never tested
      while (hi - lo > 1) {
        const mid = (lo + hi) >> 1;
        blob = await encode(mid);
        encodes++;
        if (blob.size <= maxBytes) { best = { blob, quality: mid }; lo = mid; } else hi = mid;
      }
      return { ...best, width: w, height: h, encodes };
    }

    // Even quality 10 is too big: shrink the pixels and search again.
    if (Math.max(w, h) <= 100) return null;
    const f = Math.max(0.5, Math.min(0.9, Math.sqrt(maxBytes / blob.size)));
    w = Math.round(w * f);
    h = Math.round(h * f);
  }
}

Use it like this:

const bitmap = await createImageBitmap(fileInput.files[0]);
const result = await fitToBytes(bitmap, 100 * 1024); // JPEG, at most 100 KB
if (result) {
  const url = URL.createObjectURL(result.blob); // preview it or offer it as a download
} else {
  // the limit is too small, or the browser can't write this format: tell the user
}

How it works

  1. Quality 10 first. If even that is over the limit, quality alone cannot get there. File size roughly follows pixel count, so the function scales both sides by the square root of limit divided by size (kept between 0.5 and 0.9) and tries again. It gives up when the longest side reaches 100 px.
  2. Then a binary search over whole-number qualities from 10 to 95 for the highest one that fits. Every probe is a fresh encode of the same canvas, drawn from the original bitmap, so quality loss never stacks. Every blob it returns was measured, not estimated, so the result always fits.

What it did on a test image

The test image was generated in the page: a 4000×3000 picture of gradients, soft shapes and light noise, 2,678,967 bytes as a JPEG at quality 92. It is smoother than most real photos, so treat the numbers as an example. Yours will differ, and that is the point of measuring instead of guessing.

Limit Result Encodes
1 MB (1,048,576 bytes) JPEG quality 81, 1,030,379 bytes, still 4000×3000 8
100 KB (102,400 bytes) JPEG quality 28, 100,385 bytes, still 4000×3000 7
100 KB (102,400 bytes) as WebP quality 45, 101,162 bytes, still 4000×3000 8
20 KB (20,480 bytes) JPEG quality 17, 20,265 bytes, shrunk to 1845×1384 9
300 bytes null: it kept shrinking until the longest side was 100 px or less, then gave up -

On the machine that ran this, the JPEG searches took 0.2 to 0.9 seconds each, but the 12-megapixel WebP search took 7 to 8 seconds across two runs. If you let users pick WebP, show progress or shrink the pixels first.

Gotchas

  • JPEG has no transparency. Without the white fill, the empty half of a transparent PNG came back black (pixel 0, 0, 0). With the fill it came back white.
  • Unsupported formats fall back to PNG without an error. Chromium returned a PNG blob when asked for image/avif. The function checks blob.type and returns null rather than hand back a file that ignores your size limit. Which formats a browser can write differs by browser, so check on the ones you support.
  • KB is not one number. Some forms count 1 KB as 1,000 bytes and others as 1,024. If the form doesn't say, pass 100 * 1000. It is the smaller number, so the file passes either way.
  • toBlob can pass null. MDN says it may do so if the image cannot be created for any reason, which is why the first check tests for it.

Try it without writing code

A similar search runs in a free page that takes a size in KB and finds the quality for you. It works in your browser, and the image is not uploaded: Compress an image to 100 KB

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.