Three images. Three green “within limit” results. Zero files that met the upload rules I had written down.
The compressor was doing what it promised. My acceptance check was asking a different question.
For this experiment, I defined a hypothetical destination that accepts JPEG, exactly 600 × 800 pixels, at most 200,000 bytes. Then I ran three synthetic inputs through a browser compressor's “200 KB” preset and inspected the downloaded files with a separate image decoder.
Here is the result before we get to any code:
| Downloaded result | Encoded bytes | Actual format | Actual dimensions | Meets that destination's rules? |
|---|---|---|---|---|
| Noisy PNG, compressed | 179,501 | PNG | 199 × 265 | No: wrong format and dimensions |
| Noisy JPEG, compressed | 204,128 | JPEG | 600 × 800 | No: 4,128 bytes too large |
| Small transparent PNG, unchanged | 12,736 | PNG | 600 × 800 | No: wrong format |
This was a controlled acceptance test, not a story about a real application form rejecting a customer. The “200 KB” preset explicitly meant 200 × 1,024 = 204,800 bytes. It allowed proportional resizing, preserved PNG by default, and left an already-small input untouched. All three files satisfied that policy.
I maintain ToolKnit's browser image compressor, which is the tool used for this case study. That affiliation matters: this is an engineering explanation of its limits, not an independent product review. The small implementation below is a separate teaching example, not a copy of the production algorithm.
Actual tool output from synthetic fixtures. The table above uses independently parsed downloads rather than rounded UI values.
The useful lesson is that “make it smaller” and “make it acceptable” are different specifications. A quality slider cannot decide the second one for you.
Write the destination's rules before choosing an encoder
A file-size ceiling is one field in a larger contract:
const destination = {
maxBytes: 200_000,
mime: "image/jpeg",
width: 600,
height: 800,
animation: "reject",
transparency: "flatten-on-white-with-consent",
};
Some fields are machine-checkable. Others are product decisions. Flattening a transparent asset onto white changes its appearance; it is not a harmless technical default. A portrait that must remain 600 × 800 cannot quietly become 199 × 265 just because a smaller canvas produces fewer bytes.
Ask these questions before processing:
- Does the receiver require an exact format or merely an allowed set?
- Are width and height exact, maximums, or unconstrained?
- Is the limit per file, per archive, or for the whole request?
- Must transparency, animation, metadata or a particular color profile survive?
- What is allowed to change when those requirements conflict?
If the receiver says only “200 KB,” check its documentation or actual byte limit. 200,000 bytes and 204,800 bytes differ by 4,800 bytes. The second row in the table fits one and fails the other. Formatting a number as “199.3 KB” does not resolve that ambiguity.
Use the encoded Blob.size, which is measured in bytes. Do not compare the length of a base64 data URL, a rounded display string, or the original file's size.
Quality is an encoder input, not a byte budget
This looks plausible:
canvas.toBlob(onResult, "image/jpeg", 0.85);
But 0.85 means neither “keep 85% of the original bytes” nor “produce an image of a predictable size.” It is also not a portable perceptual-quality score across codecs and browsers.
I generated two 600 × 800 canvases: one with four flat rectangles, another with deterministic RGB noise. In the same browser, using the same JPEG quality parameter:
| Synthetic input | Dimensions | JPEG quality argument | Encoded bytes |
|---|---|---|---|
| Flat rectangles | 600 × 800 | 0.85 | 7,221 |
| RGB noise | 600 × 800 | 0.85 | 344,122 |
The noise is deliberately difficult input, not a representative photo collection. This is evidence that the parameter does not enforce a budget; it is not a comparison of photo quality or a ranking of compressors.
There are two more API details worth making explicit:
-
toBlob()can returnnullthrough its callback. A completed callback is not necessarily a usable image. - An unsupported requested output format falls back to PNG. Naming the download
result.jpgdoes not make those bytes JPEG.
My unsupported-type probe requested image/x-budget-lab-test; the returned blob was image/png. An encoder wrapper should reject that mismatch when the output type is a requirement.
PNG has another trap: the canvas quality argument is intended for formats with lossy compression. In this run, encoding the transparent PNG fixture at 0.1 and 0.9 produced 12,736 bytes each, with identical SHA-256 hashes. Moving that slider did nothing to this PNG. That exact hash equality is an observation from this browser, not a promise about every implementation.
A small bounded encoder that is allowed to say no
For a fixed-dimension JPEG destination, I prefer a small, explicit policy over silently shrinking the image until some file emerges.
The following function:
- accepts an already-decoded canvas;
- explicitly composites it onto white;
- preserves the canvas dimensions;
- tries six decreasing JPEG quality settings;
- returns a success only after checking the actual encoded bytes;
- returns a refusal when none of those candidates fits.
The white background must be an accepted product choice. This function is not appropriate when alpha or animation must be preserved. Its pixel ceiling is a scope limit for the example, not a guarantee that every device can safely process that input.
It preserves the dimensions you give it; it does not crop an arbitrary source into a 600 × 800 portrait. Prepare the required canvas dimensions first, using a crop or padding policy the destination permits.
// Teaching example: a fixed-dimension, explicitly white-matted JPEG policy.
// It is NOT ToolKnit's production implementation or an optimal codec search.
function encode(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (!blob || blob.size === 0) {
reject(new Error('Encoder returned no bytes'));
} else if (blob.type !== type) {
reject(new Error(`Requested ${type}; received ${blob.type}`));
} else {
resolve(blob);
}
}, type, quality);
});
}
async function fitJpeg(canvas, maxBytes, {
qualities = [0.92, 0.82, 0.72, 0.62, 0.52, 0.42],
signal,
} = {}) {
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
throw new RangeError('maxBytes must be a positive safe integer');
}
// Copy before awaiting: caller mutation must not change this run's policy.
const candidates = Array.isArray(qualities) ? [...qualities] : [];
if (candidates.length < 1 || candidates.length > 12 ||
candidates.some(q => !Number.isFinite(q) || q < 0 || q > 1) ||
candidates.some((q, i) => i > 0 && q >= candidates[i - 1])) {
throw new RangeError('Use 1–12 strictly decreasing quality values in [0,1]');
}
const {width, height} = canvas;
if (!width || !height || width * height > 4_000_000) {
throw new RangeError('This example permits 1–4,000,000 pixels');
}
signal?.throwIfAborted();
const opaque = document.createElement('canvas');
opaque.width = width;
opaque.height = height;
const attempts = [];
try {
const ctx = opaque.getContext('2d');
if (!ctx) throw new Error('Canvas 2D unavailable');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(canvas, 0, 0);
for (const quality of candidates) {
signal?.throwIfAborted();
const blob = await encode(opaque, 'image/jpeg', quality);
// toBlob itself is not abortable; do not publish a stale result.
signal?.throwIfAborted();
attempts.push({quality, bytes: blob.size});
if (blob.size <= maxBytes) {
return {ok: true, blob, width, height, quality, attempts};
}
}
return {ok: false, reason: 'No candidate in this policy fits', attempts};
} finally {
opaque.width = opaque.height = 1;
}
}
For the hypothetical destination at the start, pass destination.maxBytes, which is 200_000. Changing the displayed unit should never silently change that integer.
The distinction in the failure message is intentional: “No candidate in this policy fits”, not “This image cannot be compressed any further.” A lower quality value, different codec, different dimensions or different encoder might fit. Those are changes to the policy, and some may violate the destination's requirements.
The function returns the first fitting value in the ordered ladder. It does not claim to find an optimal quality setting. Binary search can reduce the number of probes under suitable assumptions, but the canvas API does not define a portable byte-size function that guarantees your preferred search strategy finds the global best candidate. Whatever search you use, verify the blob you actually return.
What happened when I ran it
Using the explicit budget 204_800 and the six settings shown above:
| Fixture | Preserved dimensions | Outcome | Bytes |
|---|---|---|---|
| Flat rectangles | 600 × 800 | Fits at 0.92 | 7,778 |
| Transparent rectangles, white matte | 600 × 800 | Fits at 0.92 | 7,857 |
| RGB noise | 600 × 800 | Fits at 0.52 | 190,797 |
| Larger RGB noise | 1200 × 1600 | Refused after all six candidates | Smallest attempted: 648,881 |
The large-noise refusal is a valid result. Returning a smaller canvas would produce a different kind of success, one the caller has not authorized here.
These tables use different source representations and search policies: the production JPEG path started with an already-encoded JPEG, while the teaching function started with a canvas. Their byte counts do not establish which compressor is better.
Likewise, 0.52 passing a byte check does not prove the result is visually acceptable. Fine text, facial detail and gradients still need review in the actual destination. Keep the source so that a rejected result can be regenerated from the original rather than recompressed repeatedly.
Check the exported artifact, not just the function's return value
For blobs produced by your own encoder, a useful second check is to decode the output and inspect what it actually contains:
async function checkGeneratedImage(blob, rule) {
const problems = [];
if (blob.type !== rule.mime) problems.push("output MIME differs");
if (blob.size > rule.maxBytes) problems.push("byte limit exceeded");
const bitmap = await createImageBitmap(blob);
try {
if (bitmap.width !== rule.width || bitmap.height !== rule.height) {
problems.push("pixel dimensions differ");
}
} finally {
bitmap.close();
}
return problems;
}
This is a convenience check for an application's generated output, not a security validator for arbitrary user uploads. A client-supplied MIME label or extension is untrusted. A server must independently enforce its byte limits, recognize and safely decode allowed formats, validate dimensions and apply its own resource limits. Client-side checks improve the experience; they do not replace that boundary.
For this article, I downloaded the generated files and opened them with Pillow, separately from the browser. The verification compared format, dimensions, byte count and SHA-256, then checked alpha and selected pixels. It caught a useful visual distinction: direct JPEG encoding of the transparent canvas produced a black corner in this browser, while the explicit white-matte path produced (255, 255, 255).
I also downloaded the compressor's ZIP. Its three members were byte-identical to the individual downloads, but the archive was 396,759 bytes. Every member being below 204,800 bytes does not make the archive below 204,800 bytes. A per-file budget and an attachment budget need separate checks.
Four boundaries that deserve their own decisions
Animation. Drawing a decoded image onto one canvas gives you a bitmap, not a frame-preserving animation pipeline. Decide whether to reject animation, retain the original or use a suitable animated codec pipeline. Do not silently discard the other frames and call that a successful compression.
Orientation and metadata. Define dimensions in the orientation the receiver uses. createImageBitmap() defaults to using image orientation metadata when present. Re-encoding also should not be treated as a general guarantee that all metadata or color information was preserved—or removed. Test the fields your workflow actually depends on.
Memory. A 6000 × 4000 RGBA bitmap contains 96,000,000 bytes of pixel data before additional buffers. Compressed file size is a poor proxy for that cost. Put limits on input bytes, dimensions, decoded work and retained outputs; process batches with bounded concurrency. This example starts after decoding, so it does not solve admission control for a malicious or enormous source file.
Stale work. Someone can choose a new file while the previous encode is still running. The abort checks above prevent an aborted result from being returned, but do not cancel an in-progress toBlob() operation. The caller must abort the old run and ensure only the current run can update the UI. “I clicked Cancel” and “the encoder stopped consuming resources immediately” are different claims.
If the page processes files locally, describe that claim narrowly. Local file processing does not imply that the website makes no network requests; loading assets and ordinary analytics are separate concerns. It also does not make a page appropriate for every confidential workflow.
Reproduce the interesting part
These are synthetic fixtures, so the central experiment does not require anyone's private photos. Run the encode and fitJpeg definitions above in a browser module, then add this fixture generator and probe:
Deterministic fixtures and reproduction code
// Deterministic fixtures: deliberate test inputs, not customer photos.
function fixture(kind) {
const canvas = document.createElement('canvas');
canvas.width = kind === 'large-noise' ? 1200 : 600;
canvas.height = kind === 'large-noise' ? 1600 : 800;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D unavailable');
if (kind === 'noise' || kind === 'large-noise') {
const pixels = ctx.createImageData(canvas.width, canvas.height);
let seed = 0x12345678;
for (let i = 0; i < pixels.data.length; i += 4) {
for (let c = 0; c < 3; c++) {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
pixels.data[i + c] = seed >>> 24;
}
pixels.data[i + 3] = 255;
}
ctx.putImageData(pixels, 0, 0);
} else if (kind === 'alpha') {
ctx.fillStyle = 'rgba(22,120,200,0.5)';
ctx.fillRect(100, 100, 400, 600);
ctx.clearRect(200, 200, 200, 400);
} else if (kind === 'flat') {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, 600, 800);
['#182434', '#4276ad', '#d58456', '#94b5a2'].forEach((color, i) => {
ctx.fillStyle = color;
ctx.fillRect(40, 40 + i * 180, 520, 120);
});
} else {
throw new Error('Unknown fixture');
}
return canvas;
}
const measurements = [];
for (const kind of ["flat", "alpha", "noise", "large-noise"]) {
const canvas = fixture(kind);
const baseline = await encode(canvas, "image/jpeg", 0.85);
const fitted = await fitJpeg(canvas, 204_800);
measurements.push({
fixture: kind,
width: canvas.width,
height: canvas.height,
baselineBytes: baseline.size,
fits: fitted.ok,
quality: fitted.ok ? fitted.quality : null,
resultBytes: fitted.ok ? fitted.blob.size : null,
});
console.log(kind, fitted.attempts);
canvas.width = canvas.height = 1;
}
console.table(measurements);
The measurements here were collected on October 30, 2026, in a Windows Chromium-based browser reporting Chrome 156. Another browser or encoder version can produce different bytes. Reproduce the invariants—dimensions retained, returned type checked, successful blobs within the byte cap, explicit failure—not a claim that everyone should get the same JPEG hash.
The local harness also exercised invalid budgets, invalid quality ladders, a pre-aborted run, a one-byte refusal and unsupported-format fallback. Those checks supplement the downloaded-file verification; a green test count by itself would not establish visual quality, cross-browser compatibility or safe handling of arbitrary inputs.
The UI should report what it actually proved
Instead of showing one ambiguous “Success” badge, report the constraints independently:
Bytes: 179,501 / 200,000 PASS
Format: PNG / JPEG required FAIL
Dimensions: 199×265 / 600×800 FAIL
Decision: Do not submit this file
Then offer choices that match the failed rule: select a permitted format, obtain a suitable original, accept a resize only if allowed, or explain that the current policy has no fitting candidate. Do not keep lowering quality when the failing field is the format.
The most useful compression result is not always the smallest file. It is a file whose properties match the destination—and a clear refusal when they do not.
Which constraint has caused the most surprises in your upload flow: bytes, dimensions, alpha, or the receiver's definition of “KB”?
Disclosure: I maintain ToolKnit. AI tools produced the draft, example code and cover illustration. The numerical results came from actual browser runs and separately parsed downloads; the cover is not experimental evidence.

Top comments (0)