DEV Community

yue xing
yue xing

Posted on

Why Firefox's quality-0.9 JPEG is 56% bigger and cleaner on red text

For a course project I'm building a small poster editor, and the export button does the usual thing: draw to a canvas, then canvas.toBlob(cb, 'image/jpeg', 0.9). Before handing it in I ran the same export in three browser engines, and the file sizes didn't match by a wide margin. My test image is a 1600×900 sale poster I drew with Python: red background with a yellow headline, plus a white card with red text and a line of black text beside it for comparison. The source PNG is 123 KB. Everything ran on an Apple M4 with 16 GB of RAM, using open-source builds of Chromium 149, Firefox 151 and WebKit 26.5.

At quality 0.9, Chromium gave me 157,626 bytes and WebKit 226,166. Firefox gave me 246,096, which is 56% bigger than Chromium for the same number in the same API. My first guess was that Firefox just has a less efficient encoder. Then I zoomed in on the red text. To put a number on "looks blurry", I measured the colour difference (CIE76 ΔE) only in a 2-pixel band around the glyph edges, because the flat areas are almost error-free and would dilute a whole-image average. Chromium's file came out at an edge ΔE of 7.11 and WebKit's at 6.64. Firefox's was 2.31. The usual rule of thumb is that anything above about 5 is a fringe you can see at a glance, so the biggest file was also the only one where the red text looked clean.

The size gap turned out to be chroma subsampling, not encoder quality. A JPEG encoder first converts RGB into one luma channel and two chroma channels. With 4:2:0, the common default, each chroma channel is halved in both directions, so it keeps a quarter of the pixels, while luma stays at full resolution. With 4:4:4 nothing is thrown away before quantisation. Black text on white barely cares, because the difference between the two is almost all luma. Red on white, or yellow on red, lives mostly in chroma, and a quarter-resolution chroma channel smears it across the edge. The strongest evidence was a control that matched better than I expected. Firefox's quality-0.9 file is byte-for-byte the same size as Pillow at quality 90 with 4:4:4 (246,096 bytes), and the decoded pixels are identical. So Firefox isn't encoding worse. It's keeping more colour.

Rather than eyeball it, I read the subsampling from the file. The SOF segment in a JPEG header stores each component's sampling factors, and the luma factor alone tells 4:2:0 from 4:4:4. This works on the Blob that toBlob hands you:

async function subsamplingOf(blob) {
  const dv = new DataView(await blob.arrayBuffer());
  for (let off = 2; off < dv.byteLength; off += 2 + dv.getUint16(off + 2)) {
    const m = dv.getUint8(off + 1);
    if (m !== 0xc0 && m !== 0xc2) continue;
    const yFactor = dv.getUint8(off + 11);   // Y component: H in high nibble, V in low
    if (yFactor === 0x22) return '4:2:0';
    if (yFactor === 0x11) return '4:4:4';
    return `Y is ${yFactor >> 4}x${yFactor & 15}`;
  }
  return 'SOF not found';
}
Enter fullscreen mode Exit fullscreen mode

Run over the three quality-0.9 files, it prints 4:2:0 for Chromium, 4:4:4 for Firefox and 4:2:0 for WebKit. Then I went looking for where each engine switches. Firefox moves to 4:4:4 from quality 0.895, which rounds to 90. Chromium and WebKit only switch at 0.995, which rounds to 100. In Chromium, quality 0.99 still has an edge ΔE of 6.35, and quality 1.00 drops it to 0.19 but produces 438,482 bytes, about 3.5 times the PNG it came from. I have only tested these open-source builds, not release Chrome or Safari, and I haven't read the engine source to see why the cut-off sits there.

A poster I drew myself might be too kind to the encoder, so I repeated the Chromium run on a ready-made template I found online: a 2026 National Day holiday notice, a template image from Gaoding Design, 1242×2688 with gradients and illustrations. It went the same way. Going from quality 0.80 to 0.99 made the file 3.2 times bigger, while the edge ΔE on its red text only fell from 6.56 to 4.48. Only 1.00 switched to 4:4:4 and dropped it to 0.22. With Pillow at quality 90, which is what Firefox chose for my own poster, 4:2:0 gave 5.52 and 4:4:4 gave 2.62.

Screenshot of a small export-compare page: calendar corner of an online holiday-notice template, original above and JPEG 0.90 export below

This is a full-page screenshot of a small export-compare page I wrote, with that template loaded. The top pane is the original and the bottom pane is the exported file decoded back, both showing the same calendar corner at 6x, pixel for pixel. The line above the bottom pane is read by the page from the file itself: JPEG, quality 0.90, 927.8 KB, 4:2:0. Look at the small characters under the dates 1 and 2. In the export their red turns darker and brownish, with a pink halo along each stroke, while the original stays a clean red. The big digits change much less, and you have to look closely to see their edges fray.

Edge colour difference for eight text/background pairs at JPEG quality 90, 4:2:0 vs 4:4:4

This chart goes back to my own drawings. It comes from a second test image, a colour board with the same line of text in eight colour pairs. The red bars are 4:2:0 at quality 90, which is what Chromium exports. The dark bars are the same quality with 4:4:4, which is roughly the choice Firefox makes at 0.9. Black on white sits at 0.8 either way. Every pair with a real colour difference drops sharply, green on red from 19.0 to 3.1.

I also checked ImgIng, because its JPG quality slider stops at 100 and I wanted to know what 100 maps to. I used ImgIng (https://imging.ai/) as it was live on 2026-09-29, the Compress + Convert tool with default settings, in the same Chromium 149. With the slider at 100 the JPG was 270,978 bytes and byte-identical to toBlob at 0.99, with 4:2:0 in the header. So inside Chromium it can't give you 4:4:4 JPEG either. Left on its defaults, it treated the poster as line art and wrote an 8-bit PNG with 124 colours, 48,481 bytes, 62% smaller than the source, with an edge ΔE of 0.13. The UI calls that result lossless. Strictly it isn't: 2.03% of pixels differ from the source, by at most 6 levels. That 62% only applies to flat-colour images like my poster. On the online template, ImgIng classed it as a screenshot and defaulted to WebP at quality 84, 82% smaller, but with an edge ΔE of 5.12, the same level as lossy WebP.

What I changed in my project is small. I read the sampling factor of whatever the browser exports and log it next to the size, so a 56% difference between engines no longer looks like a bug. For flat-colour posters like this one I stopped exporting JPEG at all and let the colours go to PNG. That doesn't hold for the template with gradients: WebP lossless came out at 2,359,014 bytes, 2.5 times the quality-0.9 JPEG. I haven't tested photos, so I don't know how much of this carries over to them.

Top comments (0)