“Compress to 20 KB” sounds like a single encoder setting. It is not.
Image encoders accept inputs such as quality and dimensions, but they do not guarantee an exact output byte count. If an application form rejects anything at or above 20,000 bytes, a compressor needs a bounded search strategy and a strict final measurement.
Here is the approach used in Image Convert.
Convert the target to bytes once
The interface supports decimal KB and MB:
- 1 KB = 1,000 bytes
- 1 MB = 1,000,000 bytes
So a 20 KB target becomes 20,000 bytes. The accepted target range is 1,000 through 100,000,000 bytes.
The success predicate is intentionally strict:
function isBelowTarget(outputBytes, targetBytes) {
return outputBytes < targetBytes;
}
An output of exactly 20,000 bytes does not pass a 20 KB limit. This avoids a common UI bug where a file displays as “20 KB” after rounding but is rejected by the destination.
Keep an already-valid original
If the source file is already below the target and the output format is unchanged, re-encoding would only add work and could reduce quality.
The compressor returns the original bytes in that case:
if (source.size < targetBytes && outputType === sourceType) {
return keepOriginal(source);
}
This path still needs validation. The source format must be supported and decoded dimensions must stay inside the application's safety limits.
Separate encoder search from acceptance
Image Convert uses browser-image-compression 2.0.2 as the direct raster compression engine. The engine receives a target and performs a bounded quality search, but the application does not treat the engine's requested maximum as proof of success.
Every returned Blob is measured again:
const candidate = await encode(source, options);
if (candidate.size === 0) {
throw new Error("The image encoder returned an empty result");
}
if (candidate.size < targetBytes) {
return candidate;
}
That second measurement is important. The user-facing promise belongs to the application, so the application must verify it.
Use a bounded three-round strategy
The search uses at most three rounds. Each encoder round is limited to seven iterations.
The first round keeps the original resolution and starts at 0.92 quality. If that candidate is still too large, later rounds reduce the maximum dimension and try again.
This avoids an unbounded loop and gives the UI a clear failure state. A target can be infeasible, especially for a visually complex image, a format with limited compression flexibility, or an extremely small byte limit.
Reduce dimensions using the byte ratio
When a candidate is too large, the next maximum dimension is derived from the relationship between the target and the measured candidate:
const ratioScale = Math.sqrt(targetBytes / candidateBytes) * 0.9;
const scale = Math.min(0.8, ratioScale);
const nextLimit = Math.floor(currentLimit * scale);
Why the square root?
For similar image content, the pixel count changes approximately with the square of a linear dimension. If the desired byte count is one quarter of the candidate, a rough first estimate is to reduce width and height to about one half.
The additional 0.9 factor leaves headroom, while the 0.8 cap guarantees a meaningful reduction between rounds. The next limit is also clamped below the current value so the search always progresses.
This is an estimate, not a mathematical guarantee. Image complexity, format, transparency, and encoder behavior all affect the result. That is why every round ends with a real byte measurement.
Preserve failures instead of creating misleading downloads
After three safe attempts, an oversized candidate is not presented as a successful download.
The error records:
- the requested target
- the smallest candidate produced
- a clear “could not be compressed below” message
In a batch, other successful files remain available. A ZIP contains successful results only. An infeasible item contributes no download.
This is better than relabeling an oversized result, rounding its displayed size down, or silently uploading the file to a server fallback.
Keep the process local and cancellable
The selected source, target setting, decoded pixels, intermediate candidates, final images, and ZIP stay in the browser. The compressor disables the library's default Worker/CDN fallback and does not switch to a cloud service when local processing fails.
The operation also accepts an AbortSignal and progress callback. Cancellation should stop the active search, while queue removal and unmounting should release retained Blobs, canvases, and object URLs.
Explain the quality tradeoff
A strict byte target can require both lower encoder quality and smaller pixel dimensions. Users should compare the result with the source before relying on it.
PNG can be especially difficult for tiny targets. JPEG and WebP are lossy when re-encoded. Metadata and animation are not preserved. Browser memory and maximum canvas dimensions also put practical limits on large sources.
The practical version of this workflow is documented in How to Compress an Image to 20KB, including decimal-byte behavior, batch results, and what happens when the target cannot be reached.
Top comments (0)