Skip to content
beginthings.
Blog

Home › Blog › Images

Images

Compress a Photo to an Exact KB Limit in the Browser

By Sourav Mahapatra · · Last updated · 7 min read

Hit the limit, keep it sharp
1. Qualitybinary search, 8 steps
2. Pixelsshrink only if needed
3. Checksize, minimum, dimensions
In this guide
  1. Why you can't just pick a quality
  2. Step 1: draw onto a white canvas
  3. Step 2: wrap toBlob in a promise
  4. Step 3: search quality, then shrink if needed
  5. Step 4: wire it to a file input
  6. The rules that trip people up
  7. What canvas compression can't do
  8. Shortcut
  9. Wrap-up
  10. Tools for this
  11. Keep reading

Anyone who has filled an Indian government or bank job form knows the upload box that says something like "Photograph: 20 KB to 50 KB, JPG only" and "Signature: 10 KB to 20 KB". A phone photo is 2 to 5 MB. You need to throw away about 99% of the file without making a face or a signature unreadable, and the form rejects you if you overshoot in either direction.

If you're building an upload flow with a hard size limit, you have the same problem in code. Here's how to solve it entirely in the browser with <canvas>, no server and no library.

Why you can't just pick a quality

canvas.toBlob(cb, 'image/jpeg', q) takes a quality between 0 and 1, but quality isn't a file-size dial. The same q gives a tiny file for a plain signature on white paper and a huge one for a detailed outdoor photo. The only reliable way to hit a size is to encode, measure, and adjust.

Two facts make that cheap:

  1. File size goes up (roughly) as quality goes up, so you can binary-search quality.
  2. Size depends on pixel count as well as quality, so when quality alone can't get there, you shrink the image.

The order matters. Lower quality first, and shrink pixels only as a last resort. A 140 × 60 signature at decent quality stays crisp; a big signature crushed only by quality turns into blocky JPEG mush.

Step 1: draw onto a white canvas

function drawToCanvas(img, width, height) {
  const canvas = document.createElement('canvas');
  canvas.width = Math.max(1, Math.round(width));
  canvas.height = Math.max(1, Math.round(height));
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fff';            // JPEG has no transparency
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
  return canvas;
}

That white fill is easy to forget. Without it, transparent pixels in a PNG signature come out black, and you get a black rectangle with a signature you can't see.

Step 2: wrap toBlob in a promise

const encodeJpeg = (canvas, quality) =>
  new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));

Step 3: search quality, then shrink if needed

async function fitUnder(img, maxBytes) {
  let scale = 1;
  for (let round = 0; round < 14; round++) {
    const canvas = drawToCanvas(img, img.naturalWidth * scale, img.naturalHeight * scale);

    // Best case: high quality already fits.
    const first = await encodeJpeg(canvas, 0.95);
    if (first.size <= maxBytes) return { blob: first, canvas };

    // Otherwise binary-search between a usable floor and the top.
    let low = 0.3, high = 0.95, best = null;
    for (let step = 0; step < 8; step++) {
      const mid = (low + high) / 2;
      const blob = await encodeJpeg(canvas, mid);
      if (blob.size <= maxBytes) { best = blob; low = mid; }  // fits: try higher
      else { high = mid; }                                     // too big: go lower
    }
    if (best) return { blob: best, canvas };

    scale *= 0.82;   // even quality 0.3 was too big: drop ~18% of width and height
  }
  return null;       // couldn't fit (very rare for limits of 10 KB and up)
}

A few choices worth explaining:

Step 4: wire it to a file input

input.addEventListener('change', async () => {
  const file = input.files[0];
  if (!file) return;
  const img = new Image();
  img.src = URL.createObjectURL(file);
  await img.decode();

  const result = await fitUnder(img, 20 * 1000);   // 20 KB, counted as 20,000 bytes so it passes either way a portal counts KB
  if (!result) return alert('Could not get under the limit.');

  const kb = (result.blob.size / 1024).toFixed(1);
  status.textContent = `${kb} KB, ${result.canvas.width} × ${result.canvas.height}px`;
  link.href = URL.createObjectURL(result.blob);
  link.download = 'photo-under-20kb.jpg';
});

Show the user the final size and dimensions before they download. Forms check both.

The rules that trip people up

Most "compress to X KB" code only handles the maximum. Real forms have more rules:

What canvas compression can't do

Shortcut

If you just need the file and not the code, I built a free page that does exactly this, preset to 20 KB with other targets from 10 KB to 500 KB. It runs in your browser, with no upload and no sign-up: compress an image to 20 KB. For a 20–50 KB exam photo, use compress an image to 50 KB, and set exact pixels first with the image resizer.

Wrap-up

Hitting a byte budget is a search problem, not a formula: binary-search JPEG quality, keep the best fit, and shrink pixels only when quality alone can't get there. Then check the rules your form actually enforces: minimum size, dimensions and format, not just "under X KB".

This guide was drafted with AI assistance and checked against the working code of the compressor before publishing.

Get a file under 20 KB now

Drop a photo or signature and get a JPG under your limit, with the final size and pixel dimensions shown before you download.

Open the 20 KB compressor

Free, no sign-up, nothing uploaded.


Written by Sourav Mahapatra, who builds BeginThings: free browser tools and BeginRooms, a 3D workspace you can walk through. Take the 30-second tour or start a free 11-day trial, no card needed.