
I run a couple of small tool sites on one cheap VPS, and the homepage of one of them has a 2736×1824 landscape photo at the top. It's a Wikimedia Commons picture I found and scaled down. A friend opened the page on a slow mobile connection and told me the image crawled in from the top, strip by strip, and he gave up before it finished. My first thought was the classic fix: serve it as a progressive JPEG so the whole frame shows up blurry first and sharpens as bytes arrive. Before touching anything I wanted to know what my site was actually serving.
Every JPEG on the site turned out to be baseline. Part of the images come from users uploading through the page, where the front end draws the file onto a canvas and calls canvas.toBlob('image/jpeg', 0.85). I read the header of that output on a Chromium 149 open-source build and it was SOF0, which is baseline. The rest are images I compress myself before uploading. For those I used ImgIng (https://imging.ai/), and the two JPGs I had exported from it on the same Apple M4 / 16 GB Mac were also SOF0, because its JPG output goes through the browser's native encoder. So anything that leaves a browser as a JPEG is baseline by default. If you want progressive, it has to happen on a server or with a dedicated encoder library.
Checking this doesn't need any library. A JPEG is a list of segments, each starting with FF and a marker byte, and the first frame marker tells you the type. This is the version I ran in Node against my files:
export function sofKind(bytes) {
let at = 2; // skip FFD8
while (at + 4 <= bytes.length) {
const tag = bytes[at + 1];
if (tag === 0xc0 || tag === 0xc1) return 'baseline';
if (tag === 0xc2) return 'progressive';
at += 2 + ((bytes[at + 2] << 8) | bytes[at + 3]);
}
return 'no SOF found';
}
It reported my homepage photo as baseline and a copy I re-saved with Pillow's progressive=True as progressive. On macOS or Linux, running file on the image gives the same answer in plain words.
Then I had to decide whether converting was worth it. At quality 85 the landscape was 1,098,272 bytes as baseline and 1,048,255 bytes as progressive, 4.6% smaller. Across my two samples and two quality levels the savings ranged from 3.9% to 6.3%. Decoding got slower. With createImageBitmap on the same Chromium build, progressive took 2.3 to 2.6 times as long, 11.1 ms versus 28.3 ms for that landscape. That's nothing for one hero image and something to think about for a grid of fifty thumbnails.
The real reason to switch is the partial state, so I simulated it. I cut each file to a fraction of its bytes and let the decoder draw whatever it could. The image above is a holiday notice template I found online (a Gaodingsheji template, 1242×2688) with only 25% of the bytes present. Look at the left panel first. Baseline stops halfway through the title line and everything below is grey. The middle one is progressive, and the whole poster is already there, just softer than the full file on the right. For the landscape at 10% of the bytes, PSNR against the original was 13.58 for baseline and 18.90 for progressive. At 50% progressive reached 30.97, close to the 36.81 of the complete file.
One more thing bit me when I wrote the server step. My first script opened the uploaded JPEG with Pillow and saved it with progressive=True, which decodes and re-encodes the image. I also forgot to pass quality, so Pillow used its default of 75 and the landscape dropped from 36.81 to 33.44 dB. Switching to jpegtran -progressive fixed that. It only reorders the existing coefficients, and the decoded pixels matched the upload exactly on both samples.
What I still can't tell you is how many seconds this saves in a real browser on a slow link. I tried throttled screenshots locally and every capture showed the image only after it had fully loaded, so the numbers above come from decoder simulation, not from the browser. For now I'm converting only the homepage image. If you want to do the same, run the header check on your five largest images first and see whether any of them are progressive already.
Top comments (0)