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.

Decode-Timing Harness Architecture A single source JPEG forks through two lossless jpegtran transcodes into a baseline file of 214 kilobytes and a progressive file of 203 kilobytes. Both files converge and then feed two measurement lanes: a CPU lane running hyperfine over djpeg to produce decode milliseconds and instructions per pixel, and a browser lane recording a Chrome trace to count Decode Image events and capture a filmstrip. One source, two entropy codings, identical coefficients — the scan layout is the only variable source.jpg 1600 × 900, q80 jpegtran -optimize single sequential scan jpegtran -progressive 10-scan default script lossless — DCT coefficients untouched base.jpg 214 KB · 1 scan prog.jpg 203 KB · 10 scans CPU lane hyperfine → djpeg ms + instructions/pixel browser lane rate-limited + trace Decode Image event count A full decode of both files must produce byte-identical output — that assertion is the harness's own self-test. Re-encoding instead of transcoding breaks it: mozjpeg's progressive path also changes the quantisation tables. Any harness that cannot pass the identical-output check is measuring two different pictures.

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.

Decode Time vs Source Megapixels, Mid-Range Android A line chart with source megapixels on the horizontal axis from zero to twelve and decode time in milliseconds on the vertical axis from zero to seven hundred. The baseline series rises from nine milliseconds at 0.23 megapixels through thirty-four, one hundred and thirty-eight, to two hundred and seventy-six milliseconds at twelve megapixels. The progressive series rises more steeply from nineteen through eighty-one and three hundred and forty to six hundred and ninety milliseconds, staying at roughly two and a half times the baseline throughout. Full decode to RGB, Snapdragon 680, libjpeg-turbo 2.1.5, single core 0 175 350 525 700 decode, ms 0 3 6 9 12 source megapixels 34 ms 138 ms 276 ms 81 ms 340 ms 690 ms progressive, 10 scans baseline, 1 scan Gap widens with area, ratio does not: 2.1× at 0.23 MP, 2.5× at 12 MP. Above ~6 MP the progressive decode alone exceeds a 300 ms interaction budget. Both series decode identical coefficient data; the only difference is entropy-coded scan structure and block smoothing.

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.

Network Arrival and Decode Image Events, Throttled Two lanes on a shared time axis running to three seconds. The baseline lane shows bytes arriving until 2.2 seconds followed by a single Decode Image block of thirty-four milliseconds, with first partial rows painting at 1.15 seconds. The progressive lane shows bytes arriving until 2.1 seconds with scan boundaries marked, and four Decode Image blocks totalling eighty-one milliseconds, the first whole-frame preview landing at 0.64 seconds. 1600 × 900 photo over a 900 kbps rate-limited origin baseline 214 KB arriving 1 × Decode Image, 34 ms 1.15 s — top MCU rows appear, lower half still empty progressive scan boundaries: 12% · 22% · 35% · 55% · 78% 4 × Decode Image, 81 ms total 0.64 s — whole frame visible at block-average resolution 0 s 1 s 2 s 3 s Decode blocks are drawn at 4× width to be visible against the network scale; their labels carry the true durations. On an unthrottled connection all five scans land inside one coalescing interval and only the final block runs.

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.

Which Scan Layout to Ship A four-quadrant chart. The horizontal axis is transfer time on the seventy-fifth percentile connection, from short to long. The vertical axis is decode headroom on the seventy-fifth percentile device, from tight at the bottom to ample at the top. Short transfer with ample headroom and short transfer with tight headroom both recommend baseline. Long transfer with ample headroom recommends progressive. Long transfer with tight headroom recommends measuring, or moving to AVIF with an out-of-band placeholder. Decode headroom on your p75 device ample tight short long transfer time on your p75 connection ship baseline file lands inside one coalescing interval — the preview never shows ship progressive 3-5 refinement paints, ~5% fewer bytes, CPU cost easily absorbed ship baseline 2.5× decode bought nothing; below 40 KB it also costs bytes measure, then decide often better: AVIF for the bytes plus an out-of-band placeholder for the wait

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();