I look after the client-side encoding path at ImgIng, and a few days ago I spent an afternoon on a sale poster with saturated red text. The poster is programmatic: 1600×900, a red (215, 0, 15) top half with a yellow headline, and a white card below with red lines at 72, 32 and 22 px. I exported it with canvas.toBlob('image/jpeg', q) in a Chromium 149 open-source build on an Apple M4 with 16 GB, then measured the mean CIE76 ΔE in a 2 px band around the glyph edges. Flat areas barely change under JPEG, so a whole-image average hides the problem. The edge band doesn't.
Raising quality from 0.80 to 0.99 made the file 2.25× bigger (120,501 → 270,978 bytes). Edge ΔE only went from 8.11 to 6.35. At 1.00 it dropped to 0.19, and the file jumped to 438,482 bytes, 3.5× the 123 KB source PNG. That cliff between 0.99 and 1.00 is not about quantization. It's the chroma subsampling changing from 4:2:0 to 4:4:4.
Subsampling is the knob that matters
To isolate it I re-encoded the same poster with Pillow (libjpeg-turbo) and changed only the subsampling:
| Quality | 4:2:0 bytes / ΔE | 4:2:2 bytes / ΔE | 4:4:4 bytes / ΔE |
|---|---|---|---|
| 75 | 120,513 / 8.64 | 137,543 / 6.27 | 169,640 / 4.47 |
| 90 | 173,026 / 7.11 | 198,266 / 4.46 | 246,096 / 2.31 |
| 95 | 221,333 / 6.66 | 253,455 / 3.80 | 315,356 / 1.31 |
from PIL import Image
im = Image.open("poster.png").convert("RGB")
im.save("q75-444.jpg", "JPEG", quality=75, subsampling="4:4:4")
Quality 75 at 4:4:4 is 169,640 bytes with an edge ΔE of 4.47. The browser's 0.99 at 4:2:0 is 270,978 bytes with 6.35. The 4:4:4 file is 37% smaller and cleaner. Holding quality fixed, 4:4:4 costs you 41–42% more bytes than 4:2:0. That's a real cost, but it buys far more on red text than pushing quality ever does.
A programmatic poster is easy to dismiss as a hand-picked case, so I also ran a ready-made template I found online: a 2026 National Day holiday notice, one of Gaoding Design's template images, 1242×2688, with a red header, cream cards with red text and a red calendar grid. The picture above is a 6× crop of three calendar cells, the dates 1, 2 and 3 with their lunar-calendar labels underneath. It has no labels of its own. The top row is the original, the middle row is Pillow at quality 75 with 4:4:4, and the bottom row is the browser's 0.99 at 4:2:0. Look at the small characters under each date. In the bottom row they go darker and slightly brown, while the top two rows keep the same clean red. The middle file is 680,616 bytes, about a third of the browser's 2,004,878, and it's still the cleaner one. The trend matched the poster. Going from 0.80 to 0.99 in the browser made the template 3.2× bigger and only moved the edge ΔE on its red text block from 6.56 to 4.48.
The reason is how JPEG stores colour. It converts RGB into one luma and two chroma planes, and 4:2:0 keeps a quarter of the chroma samples. A glyph's outline survives only as far as the text/background difference lives in luma. That red has a luma of about 66 out of 255. Against white, a good share of the edge is carried by chroma. On darker backgrounds almost all of it is, and those were the worst cases on my test board.
Browsers don't expose it
toBlob takes a type and a quality. There's no subsampling parameter. I read the SOF sampling factors from each exported file header to see what each engine picks. Chromium 149 and WebKit 26.5 stay at 4:2:0 through 0.99 and switch at 0.995, which rounds to 100. Firefox 151 switches at 0.895, which rounds to 90. At quality 0.9 Firefox wrote 246,096 bytes with ΔE 2.31, while Chromium wrote 157,626 bytes with 7.11. The Firefox file decodes pixel-for-pixel identical to Pillow's quality 90 at 4:4:4. It isn't a better encoder, just a different default. All three were open-source builds, and I haven't checked release Safari.
What I do on our side
I tested this with ImgIng (https://imging.ai/) as it was live on 2026-09-29, default settings, the Compress + Convert entry, the same Chromium 149 build, and no upload requests in the network log. Our JPG export uses the browser's native encoder. I map the UI's 100 to 0.99 so browsers don't treat 1.0 as a special case, since WebP 1.0 in toBlob flips to lossless. The side effect is that JPG from ImgIng in Chromium is always 4:2:0, and slider 100 gave the exact same 270,978-byte file as toBlob at 0.99. For this poster, though, the default wasn't JPG at all. It was detected as line art and saved as an 8-bit PNG: 124 colours, 48,481 bytes, 62% smaller, edge ΔE 0.13. It isn't pixel-identical, since 2.03% of pixels differ by up to 6 levels. The detector and the quantizer aren't my code. That PNG-8 result only holds for flat graphics made of a few solid colours plus text. The real template has gradients and illustrations, and there lossless WebP came out at 2,359,014 bytes, 2.5× the size of the browser's JPEG at 0.9. ImgIng didn't pick PNG-8 for it either. It classified the template as a screenshot or UI image and defaulted to WebP at 84, which saved 82% but left an edge ΔE of 5.12, roughly where ordinary lossy WebP lands.
If you have to ship JPEG with saturated text, encode it server-side with explicit 4:4:4 at a moderate quality instead of pushing quality up. Then read the SOF bytes of what you actually shipped. The main numbers come from flat programmatic graphics, with one real template as a cross-check. I haven't tested photos.
Top comments (0)