compress-to-size
v1.0.0
Published
Compress an image to an exact file-size budget in the browser. Searches resolution as well as quality, so small targets are actually reached. Zero dependencies, works in a Web Worker.
Maintainers
Readme
compress-to-size
Compress an image to an exact file-size budget, in the browser.
npm install compress-to-sizeimport { compressToSize } from "compress-to-size";
const result = await compressToSize(file, { targetKB: 100 });
console.log(result.bytes); // 99_412
console.log(result.reachedTarget); // true
console.log(result.width, result.height);Zero dependencies. No upload, no server, no worker script to host. Runs on the main thread or inside a Web Worker.
Why this exists
A form says "under 100 KB". You have a 4 MB photo from a phone.
Every image library gives you a quality knob, so the usual advice is to lower the quality until it fits. Below roughly 50 KB that advice does not work. Measured over ten 12-megapixel photographs, not one reached a 50 KB budget at full resolution at any JPEG quality down to 1. Quality has a floor, and for a 12 MP image that floor sits well above the budgets people are actually handed.
The missing move is obvious once you have seen it fail: shrink the image. What is not obvious is how much. Shrink too little and you still miss the budget. Shrink to a safe thumbnail and you have thrown away resolution you did not need to lose.
So this library searches both axes:
- Find the largest resolution at which the budget is reachable at all.
- Find the highest quality that fits at that resolution.
The result is the best-looking image that still obeys the limit, rather than the first thing that happened to fit.
What it does that a quality loop does not
| | quality loop | compress-to-size |
|---|---|---|
| 4 MB photo → 500 KB | fits | fits |
| 12 MP photo → 25 KB | misses the budget entirely | fits, by reducing resolution |
| Reports what it actually achieved | usually not | reachedTarget, bytes, width, height, quality, scale |
| Already-small input | re-encodes it anyway, adding loss | returned untouched (untouched: true) |
| Unsupported output format | silently emits PNG | throws, or tells you via isFormatSupported() |
That last row is a real browser trap rather than a hypothetical.
canvas.toBlob(cb, "image/avif") does not reject when the browser has no
AVIF encoder — it quietly hands back a PNG with the quality argument ignored.
Code that trusts the requested MIME type ends up shipping a lossless PNG while
believing it produced a small AVIF. This library probes encoders by encoding a
1×1 canvas and comparing the resulting blob.type against what was asked for.
API
compressToSize(source, options?): Promise<CompressResult>
source — anything you are likely to be holding:
File, Blob, ImageBitmap, ImageData, ArrayBuffer, Uint8Array,
HTMLImageElement, HTMLCanvasElement, or OffscreenCanvas.
options
| option | default | meaning |
|---|---|---|
| targetBytes | — | Size budget in bytes. Wins over targetKB. |
| targetKB | — | Size budget in KB (1 KB = 1024 B). Omit both to encode once at maxQuality. |
| format | source's own, else AVIF > WebP > JPEG | Output MIME. Throws if this browser cannot encode it. |
| minQuality | 0.05 | Lowest quality the search may use. |
| maxQuality | 0.95 | Highest quality the search may use. |
| allowDownscale | true | Set false to search quality only. |
| minScale | 0.1 | Smallest fraction of the original dimensions allowed. |
| maxWidth / maxHeight | — | Cap the starting size before searching. Aspect ratio preserved. |
| background | "#ffffff" | Painted behind the image for JPEG, which has no alpha. |
| maxEncodes | 24 | Upper bound on encode operations. |
| signal | — | AbortSignal to cancel the search. |
| onProgress | — | Called after each encode with { encodes, bytes, quality, scale }. |
CompressResult
{
blob: Blob; // the encoded image
bytes: number; // blob.size
format: string; // MIME type actually produced
width: number;
height: number;
quality: number; // 0-1, what the winning encode used
scale: number; // 0-1, fraction of the original dimensions
encodes: number; // how many encodes the search spent
reachedTarget: boolean;
untouched: boolean; // true when the input already fit and was passed through
}reachedTarget is the one to check before promising a user a size. When no
combination within your bounds meets the budget, the smallest result found is
still returned so you can show something — but the flag will be false.
isFormatSupported(mime): Promise<boolean>
Whether this browser can really encode that MIME type, verified by encoding rather than by sniffing a string.
supportedLossyFormats(): Promise<LossyMime[]>
Every lossy format available here, in ["image/jpeg", "image/webp", "image/avif"]
order.
Recipes
Respect a hard upload limit and tell the user when it cannot be met
const r = await compressToSize(file, { targetKB: 50 });
if (!r.reachedTarget) {
status.textContent =
`Could not get below 50 KB. Smallest possible was ${Math.round(r.bytes / 1024)} KB.`;
}Keep resolution, refuse to downscale
await compressToSize(file, { targetKB: 200, allowDownscale: false });Trade resolution for fewer artifacts
The search maximises resolution first, so a very tight budget can land on a large image at very low quality — measured in Chrome, a 4000×3000 photo given a 25 KB budget comes back as 2050×1537 at quality 0.05, which is visibly blocky. If a smaller, cleaner image suits you better, raise the floor:
await compressToSize(file, { targetKB: 25, minQuality: 0.5 });
// → a smaller image, but no quality below 0.5minQuality is the knob for this trade-off. There is no universally right
answer, which is why the library takes a predictable position rather than
guessing: it keeps pixels unless you tell it what they are worth.
Inside a Web Worker — no DOM needed, OffscreenCanvas is used automatically:
self.onmessage = async ({ data }) => {
const r = await compressToSize(data.file, { targetKB: 100 });
self.postMessage({ blob: r.blob, reachedTarget: r.reachedTarget });
};Show progress on a slow, large image
await compressToSize(file, {
targetKB: 100,
onProgress: ({ encodes, bytes }) => {
bar.textContent = `try ${encodes}: ${Math.round(bytes / 1024)} KB`;
},
});Cancel when the user picks a different file
const controller = new AbortController();
input.onchange = () => controller.abort();
await compressToSize(file, { targetKB: 100, signal: controller.signal });How many encodes it costs
The search is deliberately cheap where cheapness is free. Probing a candidate resolution only asks "is the budget reachable here at all?", which one encode at minimum quality answers. Only once the largest reachable resolution is known does the quality search run, spending its encodes where they change what you see.
- Budget reachable at full size: 1 + up to 7 encodes.
- Budget needs downscaling: 1 + up to 6 + up to 7 encodes.
- Never more than
maxEncodes(default 24), which bounds the worst case.
Format notes
Output is always a lossy format, because a size budget and lossless encoding are not compatible goals — a lossless encoder has no knob to turn once the pixels are fixed. PNG input is accepted and handled; the output will be JPEG, WebP, or AVIF.
If the source is already a lossy format this browser can encode, that format is
kept, so a JPEG in gives a JPEG out. Pass format to override.
Browser support
Needs canvas.toBlob (or OffscreenCanvas.convertToBlob), which is everywhere
current. createImageBitmap is used when present and falls back to an <img>
decode when it is not — so the DOM path works on older Safari, and the worker
path needs a browser with OffscreenCanvas.
AVIF encoding is not universal. It is only chosen automatically when the probe confirms it, and requesting it explicitly on a browser without it throws rather than silently producing a PNG.
Where this came from
Extracted from the compression engine behind Image Machine, a set of browser-side image tools where nothing is uploaded — including its compress-to-a-target-size tools. The two-axis search and the measurement that motivated it were developed there and pulled out as a standalone library.
License
MIT
