DEV Community

AsyncMonk
AsyncMonk

Posted on

My upload code turned every image into JPEG. Red posters got bigger


One of my small tool sites has an upload form, and for a long time the client side did the same thing to every file: draw it onto a canvas, call toBlob with image/jpeg and quality 0.9, send that. One format on the server, smaller files, lower storage bill. That was the theory. Then I ran a red sale poster through it and got a file that was bigger than the original and visibly worse.

The poster is a flat graphic I drew with a script, not a real customer file: 1600×900, a yellow headline on a red background, and a white card with red text next to black text of the same size. The source PNG is 126,373 bytes. Everything below was measured on an Apple M4 Mac with 16 GB, in an open-source Chromium 149 build. The JPEG at 0.9 came out at 157,626 bytes. The red text picked up a dirty halo, while the black text next to it looked fine.

To put a number on "dirty" I measured the mean color difference (CIE76 ΔE) against the source, but only in a thin band around glyph edges, because the flat areas are basically perfect and would hide the problem. A ΔE above 5 is generally considered an obvious fringe. JPEG 0.9 scored 7.11. My first reaction was to try other quality values. At 0.8 the file was 120,501 bytes with the worst edges of the lot (ΔE 8.11). At 0.99 it was 270,978 bytes, 2.25 times the size at 0.8, and the edge ΔE only dropped to 6.35. At 1.0 it suddenly went clean (ΔE 0.19), but the file jumped to 438,482 bytes, about 3.5 times the source PNG.

The jump at 1.0 made sense once I read the sampling factors from the JPEG's SOF header. Every Chromium export up to 0.99 is 4:2:0, which stores the two color channels at half resolution in both directions. At 1.0 it switches to 4:4:4. Red against white, black or green differs mostly in color rather than brightness, so halving the color resolution smears exactly those edges. Black on white differs only in brightness, which is kept at full resolution, and that is why the black text survived. The browser matters here too. WebKit 26.5 (open-source build) also waits until 0.995 before switching to 4:4:4, but Firefox 151 switches from 0.895 up. At the same 0.9 setting, Firefox gave me 246,096 bytes with an edge ΔE of 2.31. Same code, a file half again as big, and much cleaner edges. I haven't checked release Chrome or Safari, so I'm not claiming anything about them.

A poster I drew with a script is tidier than anything a user uploads, so I also ran a real one: a ready-made 2026 National Day holiday notice template I found online (a template image from the Chinese design site Gaoding), 1242×2688, 3,339,216 bytes as PNG. The quality story held. Going from 0.80 to 0.99 made the browser JPEG 3.2 times bigger, while the edge ΔE on the red "holiday schedule" line only fell from 6.56 to 4.48. A quality-75 4:4:4 JPEG from Pillow was cleaner than the 0.99 one (4.29) at about a third of its size.

A self-written export check page: the

This is a full-page screenshot of a small export check page I wrote, loaded with that template. The toolbar is set to JPEG, quality 0.99, zoom 6. The top pane is the original and the bottom pane is the exported file decoded back, both showing the red "10月8日返岗" (back to work on October 8) line from the white card. The 1.91 MB and "subsampling 4:2:0" in the bottom pane's header are read by the page from the file itself. Look at the outside of each stroke: the original edges are crisp, and the export has a faint pink halo around every stroke, easiest to see on the horizontal bars. Text this size (about 40 px) only shows it on a close look; the tiny lunar-calendar dates on the same template turn visibly darker and browner at 6x in the 0.9 export.

The PNG-8 fix came from a different comparison. For that comparison I used ImgIng (https://imging.ai/) in the same Chromium build on the same Mac, default settings, with no upload request in the network log. It detected the poster as line art on its own and chose PNG-8: 124 colors, 48,481 bytes, edge ΔE 0.13, labeled 62% smaller. Its UI calls that lossless, but when I diffed it against the source, 2.03% of pixels differed by up to 6 per channel. I can't see that at 5x zoom, but it isn't lossless. When I forced JPG, it suggested quality 88 and warned the result was 17% bigger than the original. Pushing its slider to 100 gave the same file as my own toBlob at 0.99, byte for byte, so in Chromium its JPEG is 4:2:0 as well. PNG-8 being smaller and cleaner only holds for flat graphics like this poster, though. The real template has gradients, illustrations and lanterns, and there lossless WebP was 2,359,014 bytes, 2.5 times the JPEG at 0.9. ImgIng didn't pick PNG-8 for it either. It detected a screenshot/UI and defaulted to WebP at quality 84, 82% smaller, with an edge ΔE of 5.12, the same level as lossy WebP.

My upload code now checks what kind of image it has before picking a format. Flat graphics with a handful of colors and text go to PNG-8, and the rest still go to JPEG. I haven't tested photos, so I don't know where the cutoff should sit for them yet. If your pipeline also forces JPEG, run one red-background poster through it, compare the output bytes with the input, and zoom in on the text edges before trusting the savings.

Top comments (0)