Our admin console has a "save poster" button for campaign pages: the frontend draws the poster on a canvas and exports it with canvas.toBlob('image/jpeg', 0.8). Before shipping it I ran it against a test poster I drew programmatically (1600×900, red background with yellow headline, a white card with red text at 72, 32 and 22 px, plus black text of the same sizes for reference). The 22 px red line came out with a pinkish fringe. The black line next to it was clean. Raising quality to 0.95 didn't fix it. So I measured instead of guessing, on a 16 GB Apple M4 with open-source builds of Chromium 149, Firefox 151 and WebKit 26.5.
The cause is chroma subsampling. JPEG stores brightness for every pixel, but in 4:2:0 mode it keeps only one colour sample per 2×2 block. Black on white differs mostly in brightness, so it survives. Red on white differs mostly in colour, and a quarter-resolution colour channel smears across the stroke edge. I scored "clean" as the mean CIE76 ΔE over a 2 px band around the glyph edges. In Chromium, going from quality 0.80 to 0.99 made the file 2.25× bigger (120,501 to 270,978 bytes) while edge ΔE only dropped from 8.11 to 6.35. At 1.00 the encoder switched to 4:4:4 and ΔE fell to 0.19, but the file was 428 KB, 3.5× the source PNG. A ΔE above 5 is commonly treated as clearly visible, so 0.99 is still visibly dirty.
A programmatic poster is a tidy test case, so I repeated the sweep on a ready-made 2026 National Day holiday notice template I found online (a template image from Gaoding Design, a 1242×2688 true-colour PNG of 3,339,216 bytes). It points the same way. From 0.80 to 0.99 the JPEG grew 3.2× (626,136 to 2,004,878 bytes) while edge ΔE on its red body text only went from 6.56 to 4.48. Only 1.00 switched to 4:4:4 (ΔE 0.22), at 3,643,511 bytes, larger than the source PNG.
This crop is the line 「10月8日返岗」 ("back to work on October 8") from that template, magnified 5×. Top to bottom: the original, then browser JPEG at 0.80, 0.99 and 1.00. The first three exports are 4:2:0 in the file header and only the last is 4:4:4. Look just outside the strokes. Rows two and three carry a faint pink halo and the red reads slightly darker, and 0.99 is hard to tell apart from 0.80. The bottom row matches the original. The text is about 40 px tall, so you have to look closely here; the small calendar characters on the same template show the dulling much more plainly.
What this table shows is where each engine flips from pink (4:2:0) to green (4:4:4); n/t marks a step I didn't test on that engine. I read the sampling factors straight from each file's SOF header rather than inferring them. Chromium 149 and WebKit 26.5 stay at 4:2:0 through 0.99 and flip at 0.995, because Chromium rounds quality to an integer and 0.995 becomes 100. Firefox 151 is already 4:4:4 at 0.90; a finer sweep put its threshold at 0.895, which also rounds to 90. At quality 0.9 the same poster came out at 157,626 bytes from Chromium and 246,096 bytes from Firefox. The Firefox file is byte-for-byte the same size as Pillow at quality 90 with 4:4:4, and it decodes to identical pixels. The size gap is the subsampling mode, not a better or worse encoder. These are open-source builds; I haven't verified the Firefox or WebKit behaviour on release Firefox or Safari.
That means "it looks fine on my machine" is a trap. A teammate on Firefox would approve the export at 0.9 while Chromium users get the fringe. I now check the output instead of the browser, with a small helper that reads the luma sampling factor from the blob:
async function lumaFactor(blob) {
const view = new DataView(await blob.arrayBuffer());
let at = 2;
while (at + 4 < view.byteLength) {
const kind = view.getUint8(at + 1);
if (kind === 0xc0 || kind === 0xc2) {
const hv = view.getUint8(at + 11);
return `${hv >> 4}x${hv & 15}`; // "1x1" = 4:4:4, "2x2" = 4:2:0
}
at += 2 + view.getUint16(at + 2);
}
return null;
}
Run on the three quality-0.9 exports it returned 2x2 for Chromium, 1x1 for Firefox and 2x2 for WebKit. It only understands baseline and progressive SOF markers, which is all toBlob produces in my tests.
For comparison I also ran the same poster through ImgIng (https://imging.ai/) in the same Chromium 149 build with default settings. Its quality slider set to 100 produced a JPG that is byte-identical to toBlob at 0.99, so it is 4:2:0 as well, and the UI warned it was 114% larger than the source. Left on defaults, it didn't pick JPG at all: it detected line art and wrote a 124-colour PNG-8 of 48 KB. That choice only fits flat art. On the holiday template it classified the image as a screenshot and defaulted to WebP at 84, reporting an 82% saving, with edge ΔE 5.12, the same level as any lossy WebP.
Where I landed: if the export must be JPEG and red text must be clean, pass 1.0 and accept the size, with a comment explaining why it isn't 0.9. For posters that are flat colour plus text, export PNG instead. That only holds for flat art: on the template, lossless WebP came to 2,359,014 bytes, 2.5× the quality-0.9 JPEG. There a 4:4:4 JPEG from the server is the better trade, since quality 75 at 4:4:4 (680,616 bytes, ΔE 4.29) was cleaner than the browser's 0.99 at about a third of the size. The one thing I haven't measured yet is whether a 428 KB save feels slow on phones.

Top comments (0)