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.
// 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
}
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.
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.100 * 1000. It is the smaller number, so the file passes either way. toBlob can pass 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