66 quality level runs measured, and 55 of them saved 0 bytes
A PNG comes out a few hundred bytes smaller, so I fed the result back in for a second pass and then a third, and the file size barely moved. What is stranger is that a few images really did end up larger than the original after re-encoding, while that line in the interface still read "saved". I took 11 PNGs through 6 quality levels, which is 66 input to output runs in all: 55 of them handed the original bytes straight back and saved 0 bytes, and only 11 runs actually swapped the encoding. At level 80 the only file that changed was noise256, going from 224476 B to 224243 B, a saving of 233 B or 0.1%.
Compressing all 11 at once, the statistics bar read "11 images | original: 622.6 KB -> compressed: 622.4 KB | saved: 233 B (0.0%)": the total 637552 B became 637319 B, and 0.0365 came out through toFixed(1) as 0.0%.
IDAT is the whole file, metadata is only the crumbs
Opening it chunk by chunk: meta400 is 1053 B in total, IHDR:25,gAMA:16,sRGB:13,pHYs:21,tEXt:39,tEXt:44,tEXt:30,IDAT:845,IEND:12, and every chunk carries a fixed 12 B of header and trailer. The IDAT payload itself is 833 B, while gAMA, sRGB, pHYs plus 3 tEXt chunks add up to 208 B, so stripping every one of them saves 163 B, that is 15.5%. Of the 11 images only that single one carries ancillary chunks; for the remaining 10 the stripping gain is 0 bytes.
For the files with no metadata at all, IDAT accounts for 99.97% to 99.99%. The only thing left to touch is that DEFLATE stretch of bitstream, and how much it still has to give was decided by the image content long before I got there: uncompressed scanlines divided by the actual bytes gives 1440600 / 2311 = 623.4:1 for solid800, and only 262400 / 224476 = 1.17:1 for noise256. Every row of noise is fresh data, so the back references cannot dig out a single reusable stretch.
Change the deflate level and most samples grow instead
Pure lossless re-encoding only changes the compression level and the per row filter, with zero pixel edits. Four samples:
| Sample | Original | Level 1 + filter 0 | Level 9 + adaptive | Level 9 + Paeth |
|---|---|---|---|---|
| grad256 | 681 | 181297 | 681 | 681 |
| solid800_loose | 2789 | 7815 | 2312 | 2311 |
| noise256 | 224476 | 224562 | 230212 | 224476 |
| big2560 | 399126 | 1595575 | 399126 | 405031 |
Only solid800_loose, whose encoding had been written loosely, comes down from 2789 to 2311, saving 478 B or 17.1%. Everything else stays flat or gets bigger, and noise256 pushes its 224476 up to 230212, an increase of 5736 B. A wrong filter strategy is worse still: grad256 at level 1 with filter 0 lands at 181297 B, which is 266 times its 681 B original.
The pipeline really does a JPEG pass first
Those 55 "saved 0 bytes" runs are not the encoder slacking off: something lossy happens in the middle. The slider at 80 divided by 100 gives 0.8, then adjustedQuality = quality * quality squares it, so the value that actually reaches JPEG is 0.6400000000000001, which is not even equal to 0.64; at level 100 the whole chain short circuits and the original file is delivered untouched. The second toBlob on the canvas passes only 'image/png', and a quality parameter never appears anywhere in that call. I collected the bytes level by level myself on the PNG compression page.
That lossy step does cut the size: alpha240 comes in at 6429 B and the intermediate JPEG is left with only 2507 B. But the JPEG specification carries no Alpha channel, so the transparent area gets composited onto a fully opaque background, and resealing it as PNG inflates it to 47688 B, 7.4 times the input. big2560, which has no transparency whatsoever, still climbs from 359872 B to 1905316 B, a factor of 4.8. The fallback test pngBlob.size >= item.file.size refunds on equality as well, so the delivered size is never larger than the input and the interface is left with only "no change": the inflation lives in that middle step, and the transparency survives as a side effect of the refund, not as something the compression preserved.
Fig. 1 | the four byte stages at level 80, and resealing as PNG is where the inflation happens.
Both readings of compress it three times converge
Clicking "compress all" 3 times inside the same list leaves out1, out2 and out3 byte for byte identical for all 11 images: the list item keeps holding on to that original File object, and nothing anywhere rewrites it. Feeding the output back in by hand is the second reading, and only then does anything move, and only for noise256: 224476, 224243, 224050, 224050, so the first pass takes off -233 B, the second takes off -193 B, the third takes off nothing, a cumulative drop of 426 B or 0.19%. Across the same batch the five small images 115, 127, 681, 2311 and 2789 show a cumulative drop of 0.0% over three passes.
Fig. 2 | the only sample still moving across three chained passes, bar length scaled to the delta.
The lever that actually shrinks a file is color representation
Only the palette quantization group brings gains by an order of magnitude: quantizing solid800 down to a single color turns 2311 B into 560 B, saving 1751 B or 75.8%, with 0 pixels changed and a maximum Euclidean error of 0, so for that particular image it is lossless in the honest sense. Quantizing big2560 to 8 colors turns 399126 B into 84226 B, saving 314900 B or 78.9%, at the price of 1165703 changed pixels and a maximum error of 255. Quantization loses too: alpha240 with its 254 distinct RGBA colors has an error of 0, yet the indexed version needs 22144 B, larger than the 6429 B original. grad256 and meta400 each hold 65536 colors, and cutting them to 256 colors costs errors of 285 and 247. The order of work should be to count colors and repeating patterns first, and only then decide whether to reduce the palette, shrink the dimensions or switch format; the trade offs are written up in how to pick an image compression format.
The word saved runs two different arithmetics
A single list row has only 3 branches: when something was saved it prints the bytes removed (-233 B, or -17993 B displayed as -17.6 KB), and equality and growth both print "no change", while a file that was never compressed prints "pending". There is no percentage anywhere. The only percentage on the whole page sits in the batch statistics, its denominator is the sum of every file, and the bytes column even takes an absolute value: when saved = totalOrigin - totalResult comes out negative it still displays as a positive number, while the ratio keeps its minus sign, so when a branch without any size refund compresses a file upward, the very same line can be read as "saved: 1.2 KB (-3.4%)". The straight through short circuit at level 100 is the one described in that run with quality pushed to the far right.
Q: The statistics bar says "saved 233 B (0.0%)", is the math broken?
The math is fine, the unit is just too coarse: the denominator is the total of 11 images, 637552 B, and 233 B is only 0.0365% of that, which toFixed(1) renders as 0.0%. Inside the list, 10 rows read "no change" and a single row reads -233 B.
Q: So can a PNG still be made smaller?
Anything high in entropy and already tightly encoded only grows: noise256 at 224476 B becomes 230212 B as soon as you switch it to adaptive filtering. Only the loosely written solid800_loose has 478 B left to give.
Q: The result grew but the line says "saved", how would I catch that?
Watch the minus sign. On the PNG path even equality triggers a refund, so a larger file never reaches you, and the price of that is that everything shows up as "no change"; only where that fallback is missing does "saved: 1.2 KB (-3.4%)" leak out.
The tool is not underperforming. IDAT takes 99.97% or more of the file, the repeats DEFLATE can still find are down to 1.17:1, and color representation is the only dimension left where anything can be moved. Three passes end with 426 B, 0.19%, and everything stops at 224050 B.