"Just serve progressive JPEGs" is one of those tips that gets repeated without numbers. I work on the on-device codec side of an image tool, so I sat down and measured both halves of the trade on the same two images: how many bytes progressive saves, and how much extra time the decoder spends to get them back. On my samples it saved 3.9% to 6.3% and decoded roughly 2.3 to 2.6 times slower.
The samples are two images I found online. One is a landscape photo from Wikimedia Commons, downscaled from 5472×3648 to 2736×1824 so it behaves like a hero image. The other is a holiday notice template from a Chinese design-template site, 1242×2688, mostly flat color and text. I encoded each one four times with Pillow 11.3 (libjpeg-turbo underneath, optimize=True on every file): baseline and progressive, at quality 75 and 85. The machine is an Apple M4 with 16 GB, and the browser is an open-source build of Chromium 149.
For decode time I did not trust a single call, because the first decode of anything tends to include warm-up. This is the function I ran in the page. It decodes the same blob seven times and keeps the median.
// runs inside the page: median createImageBitmap time for one JPEG blob
async function decodeMedianMs(blob, runs = 7) {
const samples = [];
for (let i = 0; i < runs; i++) {
const t0 = performance.now();
const bitmap = await createImageBitmap(blob);
samples.push(performance.now() - t0);
bitmap.close();
}
samples.sort((a, b) => a - b);
return samples[Math.floor(runs / 2)];
}
I close each bitmap right away so seven full-size decodes of a 5-megapixel photo don't pile up in memory while the clock is running. The median of seven is also what the numbers below use.
Read the chart as two separate panels. On the left, file size in KB (bytes divided by 1024): each gray baseline bar and its blue progressive twin are almost the same height. The landscape at quality 85 goes from 1,098,272 to 1,048,255 bytes, which is under 50 KB saved. On the right, decode time in milliseconds: every blue bar towers over its gray one. The same landscape file takes 11.1 ms as baseline and 28.3 ms as progressive. The template goes from 7.5 ms to 19.2 ms at quality 85.
My understanding of why the gap goes this way is fairly plain. The quantized coefficients are identical in both files, so picture quality is identical. Progressive just splits those coefficients into several scans, each with its own Huffman table, and that squeezes out a few percent. The price is that the decoder has to hold the whole coefficient buffer and walk it once per scan, instead of finishing each 8×8 block in one pass. Why the flat-color template saves a point or two more than the photo, I have not taken apart scan by scan. My guess is its high-frequency coefficients are almost all zero, but it is a guess.
What you buy with those milliseconds is a whole-picture preview on a slow connection. I simulated a partial download by truncating the files and decoding whatever was there. With half the bytes, the progressive landscape was already at 30.97 dB PSNR against the original, while the baseline one only had its top half painted. That is a decoder simulation, not a browser recording. I tried throttled screenshots too, and every frame came out as the fully loaded image, so I have no numbers for how much sooner anything shows up on screen.
This also touches my own work. In ImgIng, JPG export goes through the browser's native encoder, and canvas.toBlob only emits baseline. When I checked, I used two JPGs exported from ImgIng (https://imging.ai/) during an earlier poster test, read on the same M4: both headers are SOF0. So if progressive is what you want, it has to happen on a server or with a dedicated encoder library.
My own rule after this: one large above-the-fold image on a slow network is worth converting server-side. A grid of thumbnails is not, since they download fast anyway and each one still pays the extra decode. To check your own images, drop your hero image into decodeMedianMs as both variants and compare the two medians.
Top comments (0)