My course project this term is a small photo board, and its upload form rejects anything over 200 KB. I wrote the obvious thing first: a binary search on the quality argument of canvas.toBlob, over 0.30 to 1.00, stopping once the interval was narrower than 0.01. It worked. What bothered me was the log. Near the end of a search the byte count stopped moving while quality kept changing, and the last two steps printed exactly the same size. I assumed I had a loop bug that re-encoded the same value, spent an evening on it, and found nothing wrong with the loop. This is the write-up of what was actually going on, what I changed, and one part I still can't explain.
Why does JPEG quality 0.955 give the same file as 0.964?
I stopped searching and encoded fixed quality values instead, on an Apple M4 with 16 GB of RAM and a Chromium 149 open-source build. The test image was a 1440×2400 screenshot of a help page, text plus flat UI. 0.950 and 0.951 both produced 336,289 bytes. Every value I tried from 0.955 to 0.964 produced 362,299 bytes. Five different inputs, one output. 0.965, 0.969 and 0.970 all came out at 393,304 bytes. That pattern fits rounding to a whole number: 0.955 already counts as 96, while 0.951 is still 95. So the JPEG encoder in this browser has 101 usable quality values, 0 to 100, and anything in between is one of those 101 files. I haven't found the line in Chromium that does the rounding, so "it rounds to the nearest integer" is my reading of the numbers, not something I've confirmed in the code.
Look at the JPEG column first. It only changes at 0.955 and 0.965, where the rounding flips. The WebP column next to it keeps moving where JPEG stays flat. For example, 0.960 gives 193,098 bytes and 0.961 gives 195,376. So WebP in the same browser doesn't jump in whole-number steps. The screenshot is a single run.
What the extra precision was costing
Once the step size is 0.01, a float search that keeps halving below that is re-encoding inside a step it has already measured. I compared it with a search over the integers 30 to 100 on the same machine. Fitting the screenshot under 200 KB as JPEG, the float version took 8 encodes and 72 ms, the integer version 6 encodes and 49 ms, and both returned the same file at 99.1% of the byte budget. On a real photo with a 2 MB target the float search ended at 0.962 and the integer one at 0.96. That's the same step, the same file, and again 8 encodes against 6.
Two encodes are nothing on a screenshot JPEG. For WebP they add up, because one WebP encode of a 3840×2160 photo took a median of 321 ms here, against 21 ms for JPEG.
What I changed
The search now runs over integers and divides by 100 only at the toBlob call. I kept WebP on the integer search as well, even though WebP does react to finer values. Neighbouring integers are already close (0.96 to 0.97 moved the screenshot from 193,098 to 200,224 bytes), and I'd rather have a fixed number of encodes. I may be leaving a few kilobytes unused there. To see what a finished tool exposes, I used ImgIng (https://imging.ai/) on the same M4 Mac, Chromium 149, and its compression quality slider goes from 30 to 100 in whole numbers. Before this I'd have called that a UI shortcut. Now it reads to me like a slider that matches what the JPEG encoder can tell apart.
What I still don't understand is why WebP isn't rounded too, when both go through the same toBlob call. My guess is that libjpeg takes quality as an int and libwebp takes a float, and the browser just passes the value along. I haven't checked that, so treat it as a guess. I also only tested one browser on one laptop, and I don't know how other engines handle it.
If your own code has a quality search, print the byte count next to every quality it tries. When JPEG gives you the same number twice in a row, those encodes can't change the result, and you can switch the search to integers.
Top comments (0)