Progressive JPEG vs Baseline Decode Timing
Flipping progressive: true in a build config is a two-sided trade: a progressive JPEG usually ships a few percent fewer bytes and shows a whole-frame preview early, but its decoder does substantially more work, and that work lands on the main thread of whatever phone your user is holding. The decision needs two numbers — decoder CPU per megapixel on your slowest supported device, and the gap between first pixels and final pixels on your slowest supported connection — and neither appears in Lighthouse, WebPageTest’s metric summary, or any PerformanceObserver entry. The measurement is also easy to get wrong in one specific way: if the two files came from separate encodes, you are measuring the encoder’s quantiser, not the scan layout. This page builds a decode benchmark whose only independent variable is the scan structure, then reads the same effect back out of Chromium’s trace events. The conceptual model behind spectral selection and successive approximation lives in Progressive Rendering and Image Placeholders; this page is purely about getting honest timings out of it.
Prerequisite checklist
Build a pair whose only difference is the scan layout
This is the methodological core, and it is where most published comparisons fall apart. If you produce baseline.jpg with cjpeg -quality 80 and progressive.jpg with cjpeg -quality 80 -progressive, the two files do not contain the same image data: mozjpeg enables trellis quantisation and a different default scan optimisation on the progressive path, so the coefficients themselves differ. Any decode-time delta you measure is contaminated by a quality delta you did not intend.
jpegtran solves it. It rearranges entropy-coded data without touching the DCT coefficients, so a baseline file and a progressive file transcoded from the same source are guaranteed to decode to identical pixels. The scan layout becomes the only independent variable.
The complete benchmark script
One script produces the pair, proves they are equivalent, and emits a table of decode timings and instruction counts. Every flag that affects the result is annotated.
#!/usr/bin/env bash
# bench-scan-layout.sh <source.jpg> — requires libjpeg-turbo >= 2.1 + hyperfine
set -euo pipefail
SRC="${1:?usage: bench-scan-layout.sh <source.jpg>}"
WORK="$(mktemp -d /dev/shm/jpegbench.XXXXXX)" # tmpfs: no disk I/O in the loop
trap 'rm -rf "$WORK"' EXIT
# --- 1. Build the pair -------------------------------------------------------
# -copy none strips EXIF/ICC so both files have identical non-image bytes;
# a 40 KB ICC profile would otherwise skew the size comparison.
# -optimize recomputes Huffman tables for the sequential layout, so the
# baseline side is not handicapped by generic tables.
# -progressive re-emits the SAME coefficients using libjpeg's default
# 10-scan script. It is lossless: no requantisation happens.
jpegtran -copy none -optimize -outfile "$WORK/base.jpg" "$SRC"
jpegtran -copy none -progressive -outfile "$WORK/prog.jpg" "$SRC"
# --- 2. Self-test: prove the two files are the same picture ------------------
# -dct int pins the integer IDCT on both sides (the default is already int, but
# pinning it removes any doubt about SIMD path selection).
# -nosmooth disables libjpeg's block smoothing, which is a PROGRESSIVE-ONLY
# interpolation pass. Leaving it on makes a fully-decoded progressive
# file differ from the baseline and breaks this check.
if ! cmp -s \
<(djpeg -dct int -nosmooth -pnm -outfile /dev/stdout "$WORK/base.jpg") \
<(djpeg -dct int -nosmooth -pnm -outfile /dev/stdout "$WORK/prog.jpg"); then
echo "FATAL: files do not decode to identical pixels — not a lossless pair" >&2
exit 1
fi
# --- 3. Describe both files --------------------------------------------------
px=$(identify -format '%w*%h' "$SRC" | bc) # total pixels, for /pixel maths
for f in base prog; do
bytes=$(stat -c%s "$WORK/$f.jpg" 2>/dev/null || stat -f%z "$WORK/$f.jpg")
# Doubling -verbose makes djpeg print one line per scan; count them.
scans=$(djpeg -verbose -verbose -outfile /dev/null "$WORK/$f.jpg" 2>&1 \
| grep -c 'Start of scan' || true)
printf '%-5s %8d bytes %2d scan(s)\n' "$f" "$bytes" "$scans"
done
# --- 4. Wall-clock decode benchmark -----------------------------------------
# -outfile /dev/null keeps the 4 MB PPM write out of the measurement; without
# it you are timing your filesystem, and the two files tie.
# --warmup 5 fills the CPU caches and the tmpfs page cache before timing.
# --min-runs 50 is enough for hyperfine's outlier detection to be meaningful on
# a workload this short; raise it for sub-10 ms decodes.
# --export-json gives you a machine-readable record to diff between builds.
hyperfine --warmup 5 --min-runs 50 --style basic \
--export-json "$WORK/wall.json" \
--command-name baseline "djpeg -dct int -outfile /dev/null $WORK/base.jpg" \
--command-name progressive "djpeg -dct int -outfile /dev/null $WORK/prog.jpg"
# --- 5. Instruction counts (machine-independent comparison) ------------------
# Wall clock does not transfer between machines; retired instructions roughly
# do. instructions/pixel is the number to quote in a review.
for f in base prog; do
ins=$(perf stat -x, -e instructions:u \
djpeg -dct int -outfile /dev/null "$WORK/$f.jpg" 2>&1 \
| awk -F, '/instructions/ {print $1}')
printf '%-5s %12.0f instructions %6.1f per pixel\n' \
"$f" "$ins" "$(echo "$ins / $px" | bc -l)"
done
# --- 6. Scaled decode, matching what the browser actually does ---------------
# Chromium asks libjpeg for a DCT-scaled decode when the painted size is much
# smaller than the source. Measuring only the full-size decode overstates the
# cost of both files — and overstates progressive by more, because block
# smoothing runs at the reduced size too.
hyperfine --warmup 5 --min-runs 50 --style basic \
--command-name "baseline 1/2" "djpeg -scale 1/2 -outfile /dev/null $WORK/base.jpg" \
--command-name "progressive 1/2" "djpeg -scale 1/2 -outfile /dev/null $WORK/prog.jpg"
Warning: -nosmooth belongs in the equivalence check and nowhere else. Block smoothing is libjpeg’s inter-block DC interpolation, applied when a progressive file is displayed from an incomplete scan set — it is precisely the thing that makes an early progressive preview look smooth rather than blocky. Disabling it in the timing runs would remove part of the cost you are trying to measure.
What the numbers look like
The table below is the output of that script over four sizes of the same photographic source, run on two machines: an Apple M1 (desktop-class, single core pinned) and a Snapdragon 680 Android phone running the same libjpeg-turbo build through Termux. Byte figures are from the jpegtran pair, so they reflect only the entropy coding.
| Source | Baseline bytes | Progressive bytes | Δ bytes | Decode M1 base → prog | Decode phone base → prog | Ratio |
|---|---|---|---|---|---|---|
| 640×360 (0.23 MP) | 27.4 KB | 29.6 KB | +8.0% | 3.1 → 6.4 ms | 9 → 19 ms | 2.1× |
| 1600×900 (1.44 MP) | 214 KB | 203 KB | −5.1% | 11 → 27 ms | 34 → 81 ms | 2.4× |
| 3000×2000 (6.0 MP) | 812 KB | 769 KB | −5.3% | 44 → 112 ms | 138 → 340 ms | 2.5× |
| 4000×3000 (12.0 MP) | 1.61 MB | 1.52 MB | −5.6% | 88 → 228 ms | 276 → 690 ms | 2.5× |
Two effects are visible immediately. The byte saving inverts below roughly 40 KB: each additional scan carries its own Huffman table and marker overhead, and on a small image that fixed cost outweighs the coding gain from separating DC and AC statistics. And the decode ratio is remarkably stable at 2.4–2.5× once the image is large enough for per-image fixed costs to disappear — progressive decoding is not “somewhat” more expensive, it is consistently about two and a half times more expensive per pixel.
Verification
1. Prove the pair is equivalent before trusting any timing
The cmp step in the script is not decoration — run it manually the first time so you know what a failure looks like:
# Should print nothing and exit 0. Any output means you have two different
# pictures and every number downstream is meaningless.
cmp <(djpeg -dct int -nosmooth -pnm -outfile /dev/stdout base.jpg) \
<(djpeg -dct int -nosmooth -pnm -outfile /dev/stdout prog.jpg) \
&& echo "equivalent"
# Confirm the scan structure really differs. Baseline prints one
# "Start of scan" line; the libjpeg default progressive script prints ten.
djpeg -verbose -verbose -outfile /dev/null prog.jpg 2>&1 | grep 'Start of scan'
2. Count the paints Chromium actually performs
The decoder cost only matters if Chromium pays it more than once, and it does not always. Chromium coalesces progressive refinement paints on a timer: on a fast connection the whole file arrives inside one interval and the image is decoded a single time — you pay the 2.5× per-decode penalty and receive no preview at all. On a slow connection you get several decodes. The Performance panel is where you see which case you are in.
1. Serve the page from a rate-limited origin (see step 3) — DevTools throttling
is fine here because you are counting events, not timing them.
2. DevTools → Performance → record → reload → stop.
3. Expand the Main track and use the panel search (Ctrl/Cmd + F) for
"Decode Image" — newer Chrome builds label it "Image Decode".
4. Count the entries whose "Image URL" detail matches your test asset:
baseline → exactly 1 entry, immediately after the last byte
progressive → 1 entry per painted refinement, typically 3-5
5. Sum their Total Time. That sum, not the single-decode figure, is what the
main thread actually spent.
A progressive image that shows exactly one Decode Image entry is the worst possible outcome: full CPU cost, zero perceptual benefit. That is the signal to ship baseline for that asset class, or to move it to AVIF plus an out-of-band placeholder as described in generating BlurHash placeholders with sharp.
3. Reproduce the arrival profile deterministically
Browser throttling emulates bandwidth in the renderer, which is fine for counting events but adds jitter to timings. Rate-limit at the transport instead:
# A one-line origin that serves the pair at a fixed rate. --limit-rate on the
# client side is simpler than tc/netem and good enough for filmstrip work.
python3 -m http.server 8080 --directory /dev/shm/jpegbench.XXXXXX &
# Confirm the bytes actually stream rather than arriving in one burst. If
# time_starttransfer is close to time_total, something between you and the
# origin is buffering the whole response and every early scan is wasted.
curl -o /dev/null --limit-rate 110k \
-w 'ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
http://localhost:8080/prog.jpg
# Capture a filmstrip you can compare frame by frame between the two files.
npx webpagetest test http://your-host/prog-test.html \
--location "Dulles:Chrome" --connectivity 3GSlow --runs 3 --video --poll 5
The frame-by-frame diff is the only artefact that captures what the user sees, and automating it is covered in WebPageTest filmstrip diff automation for LCP.
Common mistakes
1. Re-encoding instead of transcoding
# WRONG — two encodes with different internal defaults. mozjpeg turns on
# trellis quantisation for the progressive path, so the coefficient data is
# not the same and the decode delta includes a quality delta.
cjpeg -quality 80 -outfile base.jpg source.ppm
cjpeg -quality 80 -progressive -outfile prog.jpg source.ppm
# CORRECT — one encode, two entropy codings, provably identical pixels.
cjpeg -quality 80 -outfile source.jpg source.ppm
jpegtran -copy none -optimize -outfile base.jpg source.jpg
jpegtran -copy none -progressive -outfile prog.jpg source.jpg
2. Timing a decode that writes a PPM to disk
A 1600×900 PPM is 4.3 MB. Writing it dominates the measurement and makes the two files look identical, which is why so many casual benchmarks conclude “no difference”.
# WRONG — 4.3 MB of filesystem writes per iteration swamp a 27 ms decode.
time djpeg -outfile out.ppm prog.jpg
# CORRECT — decode into the bit bucket, from tmpfs, with warmup runs.
hyperfine --warmup 5 --min-runs 50 'djpeg -outfile /dev/null /dev/shm/prog.jpg'
3. Deciding from desktop numbers
11 ms versus 27 ms on an M1 reads as “free”. The same file is 34 ms versus 81 ms on a mid-range Android, and 81 ms of main-thread work during a scroll is a visible frame drop. Always quote the phone figure, and prefer instructions per pixel when comparing across hardware — it is roughly 78 instructions/pixel for baseline and 191 for progressive at 1.44 MP, and those ratios hold across microarchitectures far better than milliseconds do.
4. Measuring the full-resolution decode when the browser scales
Chromium requests a DCT-scaled decode from libjpeg when the painted size is well below the intrinsic size — the same -scale n/8 path the script exercises in step 6. If your srcset is doing its job, most images decode at 1/2 or 1/4 and the absolute cost falls by roughly the area ratio. Benchmarking only the 1/1 decode inflates both numbers and exaggerates the progressive penalty, because block smoothing scales down with the output too. Match the scale factor to what your srcset and sizes configuration actually selects.
5. Expecting the result to show up in LCP
It will not. LCP for an image is recorded at the paint following its load event, so intermediate scan paints are invisible to the metric and the only mechanical effect on LCP is the ~5% byte difference. If your validation plan is “ship progressive, watch CrUX”, you will observe nothing and conclude the change did nothing. Validate with a filmstrip and with Total Blocking Time, which does move when you triple image decode cost across a gallery.
6. Applying one answer to every asset on the site
Thumbnails and hero images have opposite answers, and the table above shows why: below ~40 KB progressive is bigger and slower and invisible. Gate the flag on output size in your pipeline rather than setting it globally.
The gate itself is three lines in a sharp pipeline, and it belongs next to the rest of your derivative rules:
// Progressive pays off only above ~40 KB of output. Encode once to measure,
// then re-encode with the right flag — or transcode losslessly with jpegtran.
const probe = await sharp(src).resize(width).jpeg({ quality: 80, mozjpeg: true }).toBuffer();
const out = await sharp(src).resize(width)
.jpeg({ quality: 80, mozjpeg: true, progressive: probe.length > 40_000 })
.toBuffer();
Related
- Progressive Rendering and Image Placeholders — scan scripts, spectral selection and the placeholder techniques that replace an in-format preview
- Generating BlurHash Placeholders with Sharp — the out-of-band alternative when the format has no progressive mode
- When to Use WebP over JPEG in Production — the byte-size half of the same decision, for formats with no scan layout at all
- WebPageTest Filmstrip Diff Automation for LCP — automate the frame comparison that metrics cannot capture
- Sharp and FFmpeg Media Build Pipelines — where the size gate above belongs in a real derivative pipeline