DEV Community

hao jia
hao jia

Posted on

canvas.toBlob ignores a string quality, and my first check for it was wrong


Our image upload component reads its JPEG/WebP quality from a config service. That service stores every value as a string, so canvas.toBlob was being called with '0.8' instead of 0.8. Nothing threw. Nothing logged. I only noticed because a load-test report showed average JPEG size at more than twice the estimate.

What a string quality actually does

toBlob and toDataURL only honor quality when it is a number. Anything else is treated as if you passed nothing, and the engine falls back to its own default. I reproduced it on an M4 Mac with the three engines bundled with Playwright 1.61.1 (Chromium 149, WebKit 26.5, Firefox 151), using a 2000×1500 synthetic image I generated myself: gradient, noise and some geometric shapes.

A string quality is the same as no quality

Look at the ratio between each red bar and the green bar above it. In Chromium, 0.8 gives 446,131 bytes and '0.8' gives 957,988 bytes, which is 2.15x. Firefox is worse at 3.90x. Both fell back to their default of 0.92. Out-of-range values behave the same way: 1.5, -1, NaN and null are not clamped to 0 or 1, they are dropped. The Playwright WebKit build went the other direction. Its string result (1,012,982 bytes) was smaller than its own 0.8 (1,126,619 bytes). Same bug, opposite symptom.

For comparison I used ImgIng's format converter on the same synthetic samples in all three engines. What caught my eye was how it decides what a browser can encode: at page load it calls toBlob once per format on a 4×4 canvas and looks at what comes back, instead of reading the user agent. I wanted the same idea for quality: stop trusting the argument, check the output.

My first detector, and why it was wrong

The plan was simple. Encode once with no quality to get a baseline, then encode with the requested quality. If the two blobs hash the same, the quality was ignored.

const encode = (type, ...q) => new Promise(done => cvs.toBlob(done, type, ...q));
const digest = async blob => new Uint8Array(await crypto.subtle.digest('SHA-256', await blob.arrayBuffer())).slice(0, 4).join('.');

const baseline = await digest(await encode('image/webp'));
for (const q of [0.8, '0.8', 0.5, -1]) {
  const got = await encode('image/webp', q);
  console.log(q, got.type, got.size, (await digest(got)) === baseline ? 'IGNORED' : 'applied');
}
Enter fullscreen mode Exit fullscreen mode

Firefox printed what I expected: 0.8 image/webp 404558 applied, then '0.8' as IGNORED at 1,034,932 bytes. Chromium printed 0.8 image/webp 404244 IGNORED for the plain number. That is a false positive. Chromium's default WebP quality is exactly 0.8, so a correct call and a dropped call produce byte-identical files. WebKit printed image/png 7993002 IGNORED on every line. That build cannot encode WebP at all, so it silently returned the same PNG no matter what I asked for.

So "compare against the default" cannot tell you whether a quality was honored. It only tells you whether the output happens to equal the default, and defaults differ per engine: WebP is 0.8 in Chromium and 0.92 in Firefox. The check also has to look at blob.type first, or it will happily reason about quality on a file that is not even the format you requested.

What I shipped instead

I moved the check to the input side, at the component boundary where config, URL params and cached settings all arrive: const isQuality = q => typeof q === 'number' && q >= 0 && q <= 1;. NaN fails both comparisons, so it is rejected too. Strings from the config service are converted once, explicitly, and anything that is still not a number in range throws. I chose throwing over clamping. Clamping would turn a config typo like '80' into 1.0, and Chromium's JPEG at 1.0 is 5,281,692 bytes, 11.8 times the 0.8 file. After three months of a data-compliance cleanup with our legal team, I have little patience for anything that fails quietly.

Validation does not solve cross-engine consistency. A perfectly valid 0.8 still gives a WebKit JPEG 2.53 times the size of Chromium's. I also still do not know WebKit's default JPEG quality: the output matched none of the steps I scanned between 0.700 and 0.800.

If your component takes quality from anywhere outside the code, grep for where it comes from, add a type and range check at the entry point, then encode '0.8' and 0.8 in each target browser and compare the byte counts.

Top comments (0)