Images
Compress a Photo to an Exact KB Limit in the Browser
In this guide
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:
- File size goes up (roughly) as quality goes up, so you can binary-search quality.
- 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:
- Keep the best fit, not the last try. The final step of a binary search can land on a "too big" value, so store every blob that fits.
- Eight steps narrows quality to well under 0.01. More steps cost a full encode each and buy nothing you can see.
- A floor of 0.3. Below that, JPEG artefacts get ugly fast. It's usually better to lose some pixels than to go lower.
- 0.82 per round shrinks the pixel count by about a third each time, so even a 12-megapixel photo reaches a 20 KB budget in a handful of rounds.
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:
- A minimum, too. "10 KB to 20 KB" means a 6 KB file fails. Searching for the highest quality that fits under the max naturally lands near the top of the range; if you still come in under the minimum, raise the target rather than padding the file.
- Pixel dimensions. LIC's AAO notice, for example, prefers a 200 × 230 px photo and a 140 × 60 px signature. Crop and resize to those first, then compress. Fewer pixels leaves more bytes for detail.
- Format. Output a real JPEG with a
.jpgname. Renaming a PNG to.jpgdoesn't convert it, and some portals check. - Crop before you compress. Every pixel of table or wall in the frame costs bytes that should go to the face or the ink.
What canvas compression can't do
- It drops EXIF metadata. That's usually good for privacy, but don't rely on it to keep the camera date.
- It re-encodes, so feeding an already tiny JPEG back in at quality 0.95 can make it bigger. Check the original size first and skip work if it already fits.
- It can't rescue a blurry or badly lit photo. Retake in daylight against a plain background.
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 compressorFree, 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.