Progressive JPEG Delivery Strategy: Perceived Performance Through Scan Order

Back in 2018 I was debugging a product listing page for a large retailer and watched it load over a throttled 3G connection. The hero images were optimized, compressed, and served from edge cache, yet the page felt broken for almost two seconds — just blank white boxes where products should have been. Switching those same images from baseline to progressive encoding didn't make them download any faster, but suddenly users saw *something* in 200 milliseconds. That difference is what keeps people from bouncing.

Why Baseline JPEGs Feel Slower Even When They're Not

I've had this conversation with frontend teams more times than I can count: "Our images are already compressed, why does the page still feel slow?" The answer usually isn't total byte size, it's image rendering behavior. A baseline JPEG decodes top-to-bottom, line by line. Until a sizable chunk of the file has arrived, the browser has nothing useful to paint. On a fast connection you don't notice it, but on a congested mobile network or a long-distance CDN hop, that delay destroys perceived performance. Users don't have a stopwatch measuring load time; they have a feeling of whether the page is responding.

Progressive JPEG flips that model. Instead of sending the image in spatial order, it sends it in order of visual importance through multiple scans. The first scan might be only 8-12KB and gives you a full-frame, low-quality preview. Subsequent scans add detail. The browser can paint that first scan almost immediately and then refine it as more bytes arrive. When we rolled this out for a media client a few years back, we didn't change the origin infrastructure at all — we just changed the encoding pipeline at the edge. Their Largest Contentful Paint dropped by about 350ms on the 75th percentile without touching a single line of HTML. If you're thinking about where this fits in your broader stack, it pairs directly with a solid Image CDN Architecture Design: Building a Scalable Image Processing Pipeline where encoding decisions happen once at ingest rather than on every request.

# Default mozjpeg progressive encoding with custom scan script
# This controls scan order - lower frequencies first for faster preview
cjpeg -quality 80 -progressive -optimize -scans scans.txt input.ppm > output.jpg

# Example scans.txt - spectral selection tuned for perceived performance
# Scan 1: DC coefficients only (tiny, fast first paint)
0 0 0 0 0 0;
# Scan 2: First few AC coefficients (structure)
0 2 1 0 0 1;
# Scan 3-5: Remaining AC details (refinement)
0 9 1 1 1 1;
1 9 2 0 0 1;
2 63 0 0 0 1;

Scan Order Isn't Just Progressive On or Off

A lot of engineers think progressive is a checkbox — you either run `cjpeg -progressive` or you don't. That's how I treated it early in my career too, until I started looking at the actual scan scripts mozjpeg and libjpeg-turbo use. A JPEG file is made of 64 DCT coefficients per 8x8 block. Baseline sends them block-by-block. Progressive lets you reorder them by spectral selection and successive approximation. In plain terms, you get to decide: do I send the DC coefficient for every block first, then the low-frequency AC coefficients, then the high-frequency details? Or do I interleave them differently?

The default progressive scripts most tools ship with are decent, but they're not tuned for the web. They were designed decades ago for very different network conditions. For modern CDN delivery, you want the first scan to be as small as possible while still being recognizable. That typically means isolating the DC coefficients and maybe the first AC coefficient into its own scan. It'll look blocky and blurry, but it's a full image the browser can stretch into place and avoid layout shift. I learned this the hard way when we used a 10-scan script that looked beautiful when fully loaded but made the first scan 35KB — we lost most of the perceived performance win. Fewer, smarter scans almost always beat more scans. This becomes especially important when you're already doing Responsive Image CDN Automation: Generating srcset Variants on the Fly, because a bad scan order multiplied across six responsive variants just amplifies the waste.

// Cloudflare Worker / Fastly Compute@Edge: enforce progressive at the edge
// Check Content-Type and rewrite on-the-fly if origin sent baseline
async function handleImageResponse(response) {
  const contentType = response.headers.get("content-type");
  if (contentType && contentType.includes("image/jpeg")) {
    const buffer = await response.arrayBuffer();
    const isProgressive = checkSOHMarker(buffer); // 0xFF 0xC2 = progressive SOF
    
    if (!isProgressive) {
      // Re-encode to progressive with tuned scans before caching
      // Don't serve baseline from cache if we can avoid it
      const progressiveBuffer = await reencodeProgressive(buffer, {
        scans: "dc-first",
        quality: 82
      });
      return new Response(progressiveBuffer, {
        headers: {
          "content-type": "image/jpeg",
          "x-image-encoded": "progressive-dc-first",
          "cache-control": "public, max-age=31536000, immutable"
        }
      });
    }
  }
  return response;
}

// See HTTP caching semantics: https://httpwg.org/specs/
// and rendering guidance: https://web.dev/articles/

Tuning Spectral Selection for the First Meaningful Paint

So how do you actually pick the right scan order? I've tested dozens of variants on real user traffic, and the trade-off always comes down to three things: size of the first scan, number of total scans (which adds overhead), and how the browser's decoder handles incremental rendering. You don't want a first scan so tiny that it's just gray blocks — users perceive that as broken. You don't want it so large that it defeats the purpose. For most product and editorial images at 1200-1600px wide, a first scan between 6KB and 12KB hits the sweet spot. That's enough for DC + a bit of AC data to show shape and color, and it typically arrives in the first one or two TCP round trips.

The numbers below came from a test we ran last year on a catalog with ~4M daily image requests. We served the same 1200 JPEGs encoded four different ways through identical edge PoPs, measured on Moto G4 / 3G Fast emulation. Baseline isn't slower in total bytes — in fact it's slightly smaller because progressive adds ~2-3% scan header overhead — but the time until the user sees something useful is dramatically different. If you're combining this with lazy loading, remember that progressive still helps for above-the-fold content. For that decision, our team's write-up on Balancing Lazy Loading and Preloading with CDN: Optimal Image Loading Strategies covers when to progressively enhance eager-loaded hero images versus deferring offscreen ones. The scan order choice and the loading strategy have to be made together.

Encoding Configuration First Scan Size Time to First Preview (3G Fast) Total File Size SSIM at 25% Bytes Received
Baseline (non-progressive) N/A - top 15% of image only 1,420 ms 142.3 KB 0.31
Progressive Default (cjpeg 3-scan) 18.4 KB 680 ms 145.1 KB 0.71
Progressive Tuned (5-scan, DC-first) 9.1 KB 310 ms 144.7 KB 0.84
Progressive Aggressive (10-scan) 6.2 KB 285 ms 148.9 KB 0.82

From Spectral Theory to Edge Reality: Implementing Scan Order That Actually Helps

Knowing how spectral selection works is one thing — getting it to survive your encoding pipeline and CDN edge config without getting re-baselined is another. Most teams lose the benefit right at the build step. Your image optimizer, CMS plugin, or origin transform service defaults to baseline because it's 2-5% smaller on paper and nobody changed the flag. We've seen this burn teams where Lighthouse shows a 1.8s LCP on a hero image that should have had a 400ms perceived paint.

The sweet spot we've landed on after testing across ~40M image deliveries per day is pretty specific: first scan should deliver a recognizable image at 8-12% of total file size, not a 2KB blur. That typically means your DC scan (SS=0) plus the first two AC bands (SS 1-5) with successive approximation bit split of 1. If your first scan is under 5KB for a 150KB hero, you've split too aggressively and you're just adding RTTs for invisible quality steps. If it's over 20KB, you haven't gained anything over baseline. Target that 10-15KB first scan for a 1200px wide hero and you'll see First Contentful Paint drop by 120-250ms on 3G without changing total bytes.

At the edge, don't let your CDN strip progressive. Some optimization tiers will "normalize" to baseline for slightly better Brotli/Gzip ratios — turn that off. Set your origin to emit progressive and cache it as-is. And yes, strip the junk before you progressive-encode it. Running Image Metadata Stripping at CDN Edge: EXIF Removal for Privacy and Performance first typically saves 4-18KB of EXIF/XMP bloat, which matters more when that bloat would otherwise ride in your first scan. Here's our current mozjpeg baseline we deploy via Lambda@Edge and Fastly Compute:

## mozjpeg / cjpeg progressive encoding tuned for perceived performance
## 85q is our default - visually lossless for photos, 35-40% smaller than 92q
cjpeg -quality 85 -progressive -optimize -quant-table 2 \
  -scans scans-interleaved.txt \
  -outfile hero-progressive.jpg hero-src.jpg

## scans-interleaved.txt - tuned for first meaningful paint in ~12KB
# Scan 1: DC + low AC, high bits only - this IS your first paint
0 0 0 1 0 0;  # DC, bit 1 (MSB)
0 1 5 0 1 1;  # AC 1-5, bit 1
# Scan 2: Fill AC detail
0 6 64 0 0 0; # Remaining AC
# Scan 3: Refine DC + low AC with LSB
0 0 0 0 1 0;
0 1 5 1 0 0;

## Verify you actually got 4 scans and ~12KB first scan
jpegtran -copy none -debug hero-progressive.jpg 2>&1 | grep "Scan"
curl -sI https://cdn.example.com/hero-progressive.jpg | grep content-length

# CDN cache - never re-encode progressive at edge
# Varnish / Fastly VCL snippet
sub vcl_fetch {
  if (beresp.http.Content-Type ~ "image/jpeg") {
    set beresp.http.Cache-Control = "public, max-age=31536000, immutable";
    unset beresp.http.Vary; # avoid baseline/progressive negotiation bugs
  }
}

Advanced Patterns and When Progressive Bites You Back

Progressive isn't free. On HTTP/2 and HTTP/3 you're multiplexed, so that chunked rendering really shines, but on HTTP/1.1 with six connections per origin, a large progressive hero can head-of-line block your critical CSS if you haven't prioritized correctly. We prioritize progressive JPEGs at < 1/3 weight compared to CSS/JS early in the waterfall using priority: low + fetchpriority="low" for below-the-fold. For above-the-fold heroes, do the opposite — use fetchpriority="high" and preload so the first scan wins the race against your 45KB blocking CSS bundle. Don't assume the browser will figure it out; Chrome's prioritization still favors late-discovered CSS over early images.

The other gotcha is client-side decode cost. A 3000x2000 progressive JPEG decoded incrementally on a mid-tier Android device can cost 25-40ms more CPU than baseline — not huge, but stack four of them above the fold and you're adding 100ms+ to Main Thread blocking during that critical 1-2 second window. We cap progressive delivery to images >40KB and >800px wide. For thumbnails, icons, and anything under 15KB, ship baseline. That's also where you should reconsider if you even need a JPEG at all. If you're stitching 20 tiny product badges as separate progressive JPEGs, you're doing it wrong — CSS Sprite Generation and CDN Optimization: When Sprites Still Beat HTTP/2 still wins for that pattern because you avoid 20 separate decode + connection overheads. And if it's a logo or icon, you're better off with SVG Delivery Optimization Through CDN: Inline, External, and Sprite Approaches and skipping raster progressive entirely.

If your progressive images look like they're "tearing" or never refine, 90% of the time it's buffering. Check that your CDN isn't buffering the entire 150KB before flushing, and that your origin isn't using Transfer-Encoding: chunked with a 32KB buffer that holds back that critical first 12KB scan. We set edge buffer_size 8k and enable early_flush on NGINX, and on Cloudflare we disable Mirage Polish "lossless" recompression for progressive paths. Test with curl --raw -N --trace-ascii - — you should see ~3-4 distinct data bursts separated by 5-20ms, not one 150KB blob after a 600ms wait.

Frequently Asked Questions

Does progressive JPEG increase total file size vs. baseline?

Slightly, but less than you'd think. With mozjpeg's -optimize, progressive is 0.5% to 3% larger than baseline at the same quality (85q), because scan headers add overhead. We've measured ~1.8KB extra on a typical 120KB image. That cost is almost always worth it for the 150-300ms perceived improvement, but if you're serving thousands of tiny <15KB thumbnails, that 3% adds up with no perceived benefit — keep those baseline.

Will progressive JPEGs help LCP and Core Web Vitals?

Yes, directly. LCP is when the largest contentful element is *rendered*, not when bytes finish downloading. A progressive hero that paints at 12KB (after ~1 RTT on 4G) will register LCP 200-400ms earlier than a baseline that needs all 120KB. In our RUM data across retail clients, switching heroes from baseline to tuned progressive improved LCP pass rate from 71% to 83% on mobile without touching TTFB or total bytes. Just don't preload a baseline and progressive version of the same image — you'll double-download.

Should I use progressive JPEG or switch to WebP/AVIF progressive?

Ship both, but don't wait for AVIF to fix your JPEG problem. WebP doesn't have true progressive in the JPEG sense — it has incremental decode but browser support is inconsistent. AVIF progressive is still experimental behind flags in most decoders. The pragmatic stack we run at edge is: AVIF (if Accept contains avif and image >30KB) -> WebP -> progressive JPEG fallback. Your JPEG fallback will still be 35-45% of your traffic in 2025-2026, so nail that scan order. Use Vary: Accept + Content-DPR correctly or you'll cache-poison your progressive fallback.

How many scans is too many?

More than 5 scans and you're hurting, not helping. Each scan is an extra 200-400 bytes of header and forces another decode pass. Chrome will actually throttle renders if you push 8-10 scans — we've seen the 7th+ scan get coalesced into the final paint anyway. Our default of 4 scans (DC high bits -> low AC -> high AC -> refinement) is the best tradeoff we've found. Tools that default to 6-8 scans like old Photoshop "Progressive 3-pass" are outdated. Stick to 3-4 scans max unless you're hand-tuning for a specific 4K hero.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker