DEV Community

Cover image for Progressive vs baseline JPEG with mozjpeg: the byte difference
Sahadat Husain
Sahadat Husain

Posted on

Progressive vs baseline JPEG with mozjpeg: the byte difference

With mozjpeg, progressive JPEG is 0.2% to 1.2% smaller than baseline for photos 320 px and wider, and 2.3% larger for a 160 px thumbnail. The big saving is mozjpeg itself: 17% at 1920 px. One trap: in sharp, mozjpeg: true writes a progressive file even when you pass progressive: false.

What are the two layouts?

A baseline JPEG stores the image in one pass, top to bottom. A progressive JPEG stores it in several scans, coarse to fine, so the whole frame appears early and sharpens. The pixels and the quality are the same. Only the order of the data, and how well it compresses, change.

The sharp output docs list progressive with a default of false. They describe mozjpeg: true as a shortcut for four settings: trellis quantisation, overshoot deringing, optimised scans and quantisation table 3.

What does sharp write for each option?

I resized one camera photo (4964 × 2782) to six widths, encoded each at quality 80, and read back isProgressive from the output.

import sharp from 'sharp';

const src = 'camera.jpeg';
const modes = {
  'baseline': { progressive: false },
  'progressive': { progressive: true },
  'mozjpeg, baseline': { mozjpeg: true, progressive: false },
  'mozjpeg (default)': { mozjpeg: true },
};
console.log(['width'.padEnd(6), ...Object.keys(modes).map((m) => m.padStart(19))].join(''));
for (const width of [160, 320, 640, 1280, 1920, 4964]) {
  const resized = await sharp(src).resize({ width }).raw().toBuffer({ resolveWithObject: true });
  const row = [String(width).padEnd(6)];
  for (const opts of Object.values(modes)) {
    const out = await sharp(resized.data, { raw: resized.info }).jpeg({ quality: 80, ...opts }).toBuffer();
    const m = await sharp(out).metadata();
    row.push(`${out.length}${m.isProgressive ? ' P' : ' B'}`.padStart(19));
  }
  console.log(row.join(''));
}
Enter fullscreen mode Exit fullscreen mode
width            baseline        progressive  mozjpeg, baseline  mozjpeg (default)
160                3754 B             3959 P             3445 P             3445 P
320               10870 B            10937 P             9589 P             9589 P
640               31824 B            31608 P            27154 P            27154 P
1280              95335 B            95114 P            79671 P            79671 P
1920             185336 B           184585 P           153394 P           153394 P
4964             850624 B           852196 P           668029 P           668029 P
Enter fullscreen mode Exit fullscreen mode

Look at the third column. I asked for mozjpeg: true, progressive: false and got a progressive file, byte for byte the same as plain mozjpeg: true. The optimised-scans setting appears to force progressive mode, and it wins over your flag.

Note: If you need a true baseline file with mozjpeg's other tools, spell the options out and set optimiseScans: false. progressive: false next to mozjpeg: true does nothing.

So what is the real byte difference?

To isolate the scan layout, I kept mozjpeg's tools and switched only the layout:

const moz = { trellisQuantisation: true, overshootDeringing: true, quantisationTable: 3, optimiseScans: false };
const modes = {
  'moz baseline': { ...moz, progressive: false },
  'moz progressive': { ...moz, progressive: true },
  'moz + optimiseScans': { mozjpeg: true },
};
// same loop as above
Enter fullscreen mode Exit fullscreen mode
width           moz baseline       moz progressive   moz + optimiseScans
160                   3369 B                3652 P                3445 P
320                   9611 B                9850 P                9589 P
640                  27289 B               27719 P               27154 P
1280                 80676 B               81629 P               79671 P
1920                155054 B              157288 P              153394 P
4964                675911 B              683920 P              668029 P
Enter fullscreen mode Exit fullscreen mode
Width Moz baseline mozjpeg: true Difference
160 px 3,369 3,445 +2.3%
320 px 9,611 9,589 -0.2%
640 px 27,289 27,154 -0.5%
1280 px 80,676 79,671 -1.2%
1920 px 155,054 153,394 -1.1%
4964 px 675,911 668,029 -1.2%

Three things stand out:

  1. A progressive layout with the default scan script is larger than baseline at every width here: 157,288 against 155,054 bytes at 1920 px.
  2. The optimised scans are what make progressive win, by about 1% from 1280 px up.
  3. At 160 px, baseline wins by 76 bytes. A tiny thumbnail has too little data for extra scans to pay off.

Table of bytes per width for moz baseline, moz progressive and mozjpeg true; baseline is smallest at 160 px, mozjpeg true from 320 px up

What does mozjpeg cost in time?

The 1920 px encode, median of 5 runs, from a raw buffer:

baseline       12.3 ms (median of 5)
progressive    23.1 ms (median of 5)
moz baseline   60.8 ms (median of 5)
mozjpeg: true  103.9 ms (median of 5)
Enter fullscreen mode Exit fullscreen mode

mozjpeg: true is about 8 times slower than a plain baseline encode, for a file 17% smaller (153,394 against 185,336 bytes). That is a good trade for images you encode once and serve many times. It is a poor one inside a request that encodes on every hit.

Which should you pick?

  • Stored images, 320 px and up: mozjpeg: true. You get progressive rendering and the smallest file.
  • Thumbnails under about 200 px: spell out the mozjpeg tools with optimiseScans: false, progressive: false. My 160 px test saved 2.3%.
  • Encodes on the hot path: plain baseline. It is the fastest, and the bytes differ by under 1% from plain progressive.

The JPEG settings on Blinkimg's JPG compressor are measured the same way, and how we measure explains the method behind its published numbers.

How I measured

  • Machine: Apple M1 Pro, macOS, 2026-10-17.
  • Node v24.22.0, sharp 0.35.4, libvips 8.18.6, mozjpeg as bundled with sharp.
  • Source: image-compressor-sample-camera-photo.jpeg from the Blinkimg site, 4964 × 2782. Each width is a sharp resize to raw pixels, then the encode, so every option sees identical input.
  • All encodes at quality 80, default chroma subsampling (4:2:0). Byte counts are exact and repeat across runs. Times are the median of 5.

What I did not test

Decode time and how fast the first progressive scan paints in a browser. Other quality levels and other photos.


I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)