A JPEG quality slider cannot tell you whether an image will fit a 200 KB upload limit. Quality is an encoder setting; the limit is a count of bytes. The reliable final check is blob.size.
Here is a small implementation that searches for an output below a byte budget. It uses local Canvas processing and needs no server or API key. It is based on the compression workflow in Replikhurt Tools, a project developed with AI assistance. This article was also prepared with AI assistance; the algorithm has automated tests and the live output was checked by downloading the resulting JPEG.
Define the budget first
For this example, 200 KB means 200,000 bytes. If a destination uses KiB, its corresponding value is 200 × 1,024 = 204,800 bytes. Check the destination's wording and preserve any required minimum dimensions.
A practical search has three stages:
- Encode at high quality. Stop if the result already fits.
- Try low quality. If that fits, search between low and high for a better fitting candidate.
- If even low quality is too large, reduce dimensions and try again.
Dimension reduction is a tradeoff, not a free improvement. A small file can fail a form's pixel requirements or become unreadable. Always show final width and height alongside byte size.
Wrap Canvas encoding
The function below draws a decoded image into a canvas, uses a white background because JPEG cannot preserve transparency, and rejects an unexpected output format. Browsers may fall back to PNG for an unsupported requested type, so do not label every result as JPEG just because that was the request.
function makeJpegEncoder(image) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas is unavailable.');
return async (width, height, quality) => {
canvas.width = width;
canvas.height = height;
context.fillStyle = '#fff';
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(result => {
if (!result) return reject(new Error('Encoding failed.'));
resolve(result);
}, 'image/jpeg', quality);
});
if (blob.type !== 'image/jpeg') {
throw new Error('JPEG export is unavailable.');
}
return blob;
};
}
Use this encoder sequentially. Concurrent requests sharing the same canvas could overwrite each other's dimensions or pixels.
Search quality, then dimensions
This is the encoder-independent search used in the tool. It returns only a candidate whose measured size meets the limit. It can also stop an obsolete job when a new file or setting is selected.
// Encoder-independent search. The returned candidate always meets the byte limit.
async function compressToBudget({width,height,maxBytes,encode,cancelled=()=>false,onProgress=()=>{}}) {
if(!Number.isInteger(width)||!Number.isInteger(height)||width<1||height<1||!Number.isFinite(maxBytes)||maxBytes<1)throw Error('Invalid compression limits.');
const initialWidth=width,initialHeight=height;
async function attempt(quality){if(cancelled())throw Error('Compression cancelled.');const blob=await encode(width,height,quality);if(cancelled())throw Error('Compression cancelled.');if(!blob||!Number.isFinite(blob.size))throw Error('Image export failed.');return {blob,width,height,quality};}
for(let step=0;step<22;step++) {
onProgress(width,height);
const high=await attempt(.95);
if(high.blob.size<=maxBytes)return high;
const low=await attempt(.1);
if(low.blob.size<=maxBytes){let best=low,a=.1,b=.95;for(let i=0;i<8;i++){const q=(a+b)/2,candidate=await attempt(q);if(candidate.blob.size<=maxBytes){best=candidate;a=q;}else b=q;}return best;}
if(width===1&&height===1)break;
const factor=Math.min(.85,Math.max(.4,Math.sqrt(maxBytes/low.blob.size)*.92));
const scale=Math.min(width/initialWidth,height/initialHeight)*factor;
width=Math.max(1,Math.floor(initialWidth*scale));height=Math.max(1,Math.floor(initialHeight*scale));
}
throw Error('Unable to reach this file size. Try a higher limit or another format.');
}
The quality search is a bounded heuristic. File size usually responds to quality, but an encoder is not required to give a perfectly monotonic curve. This does not prove globally optimal quality. Keeping the last candidate that actually fits is what protects the byte limit.
The dimension step estimates a scale from the byte ratio and leaves some room before the next attempt. That estimate is useful for progress, but the next encode still needs a real byte check.
Connect a local file input
Create an HTML file containing the two functions above, then add this markup and script. No upload request is made by this example. Large or unsupported source images can still fail to decode or exhaust memory.
<input id="photo" type="file" accept="image/*">
<output id="result"></output>
<a id="download" hidden download="compressed.jpg">Download JPEG</a>
<script>
let generation = 0;
let downloadUrl;
photo.addEventListener('change', async () => {
const job = ++generation;
const file = photo.files[0];
download.hidden = true;
if (downloadUrl) URL.revokeObjectURL(downloadUrl);
if (!file) { result.textContent = ''; return; }
const sourceUrl = URL.createObjectURL(file);
try {
result.textContent = 'Compressing…';
const image = new Image();
image.src = sourceUrl;
await image.decode();
const output = await compressToBudget({
width: image.naturalWidth,
height: image.naturalHeight,
maxBytes: 200000,
encode: makeJpegEncoder(image),
cancelled: () => generation !== job
});
if (generation !== job) return;
downloadUrl = URL.createObjectURL(output.blob);
download.href = downloadUrl;
download.hidden = false;
result.textContent = `${output.blob.size} bytes, ${output.width} × ${output.height} pixels`;
} catch (error) {
if (generation === job) result.textContent = error.message;
} finally {
URL.revokeObjectURL(sourceUrl);
}
});
</script>
For clarity, this snippet uses the element IDs as window properties. In application code, get explicit references with document.getElementById. Cancellation prevents a stale result from being shown; it does not interrupt an encode already running inside the browser.
What was checked
In one live test, a generated random-noise PNG measuring 1,200 × 900 pixels and 3,241,953 bytes produced a downloaded JPEG of 191,008 bytes for the 200,000-byte preset. The 100,000-byte preset returned 93,545 bytes. These are individual test results, not expected compression ratios for other photographs.
Algorithm tests cover candidates within the budget, lowering quality, reducing dimensions, cancellation and an impossible target. Test your actual encoder too: a synthetic byte-size test does not establish image quality, browser compatibility or memory usage.
For production use, display errors clearly, inspect visual quality, preserve the original file, and avoid repeatedly recompressing an already lossy output. A user-selected local file avoids cross-origin canvas restrictions that can arise with remote images.
The complete workflow is available in the Replikhurt image compressor. It supports JPEG and WebP byte targets and a manual mode. This is the project discussed in this article; feedback about the search strategy and output checks is welcome.
Reference: Canvas toBlob documentation.
Top comments (0)