Progressive Rendering and Image Placeholders
Progressive rendering is the set of techniques that put something correct-looking in an image’s box before the image itself has finished arriving. It splits cleanly into two families: in-format progressive decode, where the codec itself emits a low-frequency approximation first, and out-of-band placeholders, where you ship a separate few-byte representation and swap it. This guide sits under Core Media Fundamentals & Next-Gen Formats because the choice is a codec-level decision — the reason the industry moved from progressive JPEG to BlurHash and dominant-colour tiles is that AVIF and WebP, the formats that replaced JPEG on file size, gave up the free preview that JPEG had since 1992.
The engineering stakes are specific and measurable. Progressive rendering does not move Largest Contentful Paint — LCP is attributed to the moment the image finishes loading, so a beautiful 12%-of-bytes preview earns you nothing in Core Web Vitals. What it moves is perceived latency, bounce on slow connections, and the visual stability of the page. Get the swap wrong and you convert a perceived-performance win into a Cumulative Layout Shift regression, or accidentally hand LCP attribution to a 640-byte blur.
Concept & Architecture: What “Progressive” Means at the Decoder
A baseline JPEG is a sequential DCT stream. The encoder walks the image in minimum coded units (MCUs — typically 16×16 pixels at 4:2:0 chroma subsampling), and for each MCU writes all 64 DCT coefficients of every component before moving to the next. There is exactly one scan. A decoder that has received 40% of the bytes can therefore reconstruct the top 40% of the image at full quality and nothing below it. Chromium and Gecko both exploit this: they paint partial MCU rows as data arrives, which is why a large baseline JPEG on a throttled connection appears to wipe in from the top.
A progressive JPEG reorders the same coefficients into multiple scans, controlled by a scan script. Two orthogonal mechanisms are available:
- Spectral selection (
Ss,Se): a scan carries only coefficients in a band of the zig-zag order. Scan 1 conventionally carriesSs=0, Se=0— the DC coefficient alone, which is the average value of each 8×8 block. Decoding it yields a complete image at 1/8 linear resolution, upscaled. - Successive approximation (
Ah,Al): a scan carries only the high bits of the coefficients in its band, with later scans refining the low bits.Al=1in the first DC scan means the block averages arrive at half precision and are corrected later.
The canonical mozjpeg/libjpeg-turbo default script emits ten scans. Roughly 12% of the file gives you the full-frame block average; ~35% gives recognisable edges; the last scans are pure texture refinement that most users will never consciously see. That distribution is the whole argument for progressive JPEG: the perceptual information is front-loaded into the first eighth of the byte stream.
The cost side is real. A progressive decoder cannot stream MCU rows out and forget them — it must hold the full coefficient buffer for the whole image in memory and re-run inverse DCT and colour conversion on every refinement pass it decides to paint. Peak decoder memory is proportional to width × height × components × 2 bytes for the coefficient array, on top of the output bitmap. On a 4000×3000 photo that is roughly 72 MB of coefficients, and the decode is typically 2–3× the CPU of the baseline equivalent. Chromium mitigates this by not painting every scan: it coalesces refinements on a timer, so on a fast connection a progressive JPEG often paints exactly once, having paid the extra CPU for nothing.
Why AVIF and WebP Removed the Preview
WebP has no progressive mode. Lossy WebP is a single VP8 key frame; lossless WebP is a single-pass predictive codec. libwebp does expose an incremental decoder (WebPIDecode) that emits complete macroblock rows as they arrive, and Chromium wires it up, so a very large WebP can wipe in top-down like a baseline JPEG — but there is no low-frequency pass, so you never get a blurry whole image. In practice most WebP assets on a page are small enough to arrive within one or two network round trips, and the visible behaviour is all-or-nothing.
AVIF’s progressive support exists but is effectively unavailable. AV1 supports layered coding, and the AVIF specification expresses it through the a1lx (layered image indexing) and lsel (layer selector) item properties: a single image item carries up to four spatial layers, the first of which is a low-resolution base that a decoder can render before the enhancement layers arrive. avifenc --progressive will produce such a file, and recent Chromium and Firefox builds do render the base layer early. The problems are ecosystem-shaped rather than spec-shaped:
- Layered AVIF costs 10–20% more bytes than the equivalent single-layer file, which erases much of AVIF’s advantage over WebP in production.
- Almost nothing emits it. sharp, Squoosh, and every major image CDN encode single-layer AVIF; there is no
progressive: trueswitch that works the way it does for JPEG. - Layer counts are capped at four, so the granularity is nothing like a ten-scan JPEG script.
JPEG XL is the only modern format with a genuinely first-class progressive mode — its Modular pass structure is responsive by design — and it is still not shipped in Chromium, which is the crux of the AVIF vs JPEG XL decision. The practical consequence: the moment you migrate a hero image from progressive JPEG to AVIF, you lose the preview and must replace it with an explicit placeholder. That is not a nice-to-have; it is a straight regression in perceived performance that placeholders exist to repair.
The Placeholder Taxonomy
An out-of-band placeholder is a compressed summary of the image that is small enough to inline in the HTML document, so it costs zero extra network round trips. The five techniques in production use differ by two orders of magnitude in payload and by an equally wide margin in fidelity.
Dominant colour is a single sRGB triple, typically the modal colour from a quantised histogram or the mean of a 1×1 downsample. Four bytes as a hex string. It cannot represent composition at all, but it guarantees the box is never white, which is the single largest perceived-performance win per byte in the entire list.
LQIP (low-quality image placeholder) is the original blur-up technique: encode the image at 16–40 px on the long edge, base64 the result into a data: URI, and CSS-scale it up with a blur. It is true-colour and genuinely representative because it is the image. Cost: a 32×18 WebP at quality 20 is roughly 480 bytes, which base64 inflates to ~640 characters in the HTML.
BlurHash encodes the image as a small set of DCT basis components — componentX × componentY, default 4×3, maximum 9×9 — into a compact base83 string. A 4×3 hash is 28 characters. Decoding happens on the client, either into a tiny canvas or into a CSS gradient approximation. The tradeoff versus LQIP is that you trade ~600 bytes of HTML for ~28 bytes plus a JavaScript decode.
ThumbHash is the modern successor: ~21–25 binary bytes (28–32 base64 characters) that additionally encode an alpha channel and the true aspect ratio, with noticeably better colour fidelity than BlurHash at the same size. It has the same client-decode requirement.
SQIP (SVG Quality Image Placeholder) traces the image into 8–20 primitive shapes with the primitive tool, runs the result through SVGO, and applies a Gaussian blur filter. Output is ~800–1 200 bytes of inline SVG. Its advantage over LQIP is that it is resolution-independent and needs no decode; its disadvantage is that it is by far the most expensive to generate — seconds per image, not milliseconds.
Benchmark and Spec Table
The numbers below come from a single 1600×900 photographic hero (a landscape with sky, foliage and a building), encoded once per row, loaded on a Moto G Power class device against a 1.6 Mbps / 150 ms RTT profile. “Time to first pixels” is the first frame in the WebPageTest filmstrip in which the image box is not empty; “LCP” is the Chromium largest-contentful-paint entry.
| Configuration | Asset bytes | Inline HTML bytes | Time to first pixels | LCP | Main-thread decode |
|---|---|---|---|---|---|
| Baseline JPEG q80 | 214 KB | 0 | 1 180 ms (top rows) | 2 340 ms | 34 ms |
| Progressive JPEG q80, 10 scans | 203 KB | 0 | 640 ms (whole frame) | 2 310 ms | 81 ms |
| Progressive JPEG, DC-first custom script | 205 KB | 0 | 470 ms | 2 320 ms | 88 ms |
| AVIF q50, no placeholder | 96 KB | 0 | 1 690 ms | 1 700 ms | 46 ms |
| AVIF q50 + dominant colour | 96 KB | 4 | 210 ms (flat colour) | 1 700 ms | 46 ms |
| AVIF q50 + BlurHash 4×3 | 96 KB | 28 | 260 ms | 1 705 ms | 46 + 9 ms |
| AVIF q50 + LQIP data URI | 96 KB | 640 | 230 ms | 1 715 ms | 46 + 3 ms |
| AVIF q50 + SQIP | 96 KB | 1 100 | 240 ms | 1 720 ms | 46 + 1 ms |
Four things fall directly out of this table.
Progressive JPEG halves time-to-first-pixels and does nothing for LCP. The 2 340 → 2 310 ms movement is inside run-to-run noise; the file-size delta (about 5%, typical for photographic content above ~10 KB) is the only mechanical effect on LCP.
AVIF wins LCP by 600 ms and loses first-pixels by 500 ms. This is the trade the placeholder exists to undo. An AVIF hero with a dominant-colour tile beats progressive JPEG on both axes simultaneously.
Placeholder fidelity barely moves the first-pixels number. 4 bytes and 1 100 bytes land within 50 ms of each other, because the cost is dominated by when the HTML itself parses, not by the placeholder’s size. The fidelity difference is qualitative — how much the user believes the page is nearly ready — not temporal.
Every placeholder costs a few milliseconds of LCP. BlurHash’s 9 ms client decode and the extra HTML bytes both push the real image’s start slightly later. On a page with one hero this is irrelevant; on a 60-image grid, 60 × 640 bytes of LQIP is 38 KB of extra HTML sitting directly on the critical path, and that is measurable.
LCP Attribution and the Low-Entropy Exclusion
The rule that governs everything here: for an image, the LCP entry’s renderTime is the paint that follows the image’s load event. Intermediate progressive scans are painted invalidations, not load completions, so they never create or update an LCP candidate. This is why no scan-script tuning has ever improved a Core Web Vitals score.
Placeholders are the dangerous case, because a placeholder can be a fully loaded image in its own right. If you render an LQIP as <img src="data:image/webp;base64,..."> sized to the hero’s box, that image loads almost instantly and is a legitimate LCP candidate — and since LCP only updates for larger candidates, the real photo arriving later in the same box produces no new entry. Your field data would show a 300 ms LCP for a page that visually completes at 1 700 ms.
Chromium closes this hole with the low-entropy image heuristic (shipped in Chrome 112): an image whose encoded size divided by its painted area is at or below 0.05 bits per pixel is ignored as an LCP candidate. Work the arithmetic for a 640-byte LQIP painted at 1200×675: 640 × 8 / (1200 × 675) = 0.0063 bpp, comfortably excluded. A dominant-colour 1×1 PNG scaled up is around 0.0005 bpp. The heuristic is generous enough that any sane placeholder falls under it — but it is a Chromium heuristic, not a specification, and it is not implemented in other engines’ experimental LCP work. Treat it as a safety net, not a design.
Step-by-Step Implementation
Step 1 — Encode a Progressive JPEG with an Explicit Scan Script
The libjpeg default script is a good general choice, but for a hero image you want the DC pass to land as early as possible and at full precision, so the first preview is a clean 1/8-scale image rather than a half-precision one. Write the script to a file and pass it to cjpeg.
#!/usr/bin/env bash
set -euo pipefail
# A scan script line is: components: Ss Se Ah Al ;
# Ss/Se = first/last coefficient in zig-zag order (0 = DC, 63 = last AC)
# Ah/Al = successive-approximation high/low bit position
cat > hero.scans <<'EOF'
0: 0 0 0 0; # scan 1 — luma DC at FULL precision (Al=0): sharp 1/8-scale preview
1 2: 0 0 0 0; # scan 2 — chroma DC: the preview gains colour immediately
0: 1 5 0 2; # scan 3 — low-frequency luma AC, top 6 bits only
0: 6 63 0 2; # scan 4 — remaining luma AC, top 6 bits
1 2: 1 63 0 1; # scan 5 — chroma AC, top 7 bits
0: 1 63 2 1; # scan 6 — luma AC refinement, bit 1
1 2: 1 63 1 0; # scan 7 — chroma AC refinement, final bit
0: 1 63 1 0; # scan 8 — luma AC refinement, final bit
EOF
# mozjpeg's cjpeg. -quality 80 is the usual photographic sweet spot.
# -scans applies our script; it implies -progressive.
# -tune-ms-ssim optimises the quantisation table for structural similarity
# rather than PSNR, which protects the low-frequency data the early scans carry.
cjpeg -quality 80 -scans hero.scans -tune-ms-ssim -sample 2x2 \
-outfile hero-progressive.jpg hero.ppm
# Verify the scan layout actually made it into the file.
djpeg -verbose -verbose -outfile /dev/null hero-progressive.jpg 2>&1 | grep -i "scan"
Tradeoff: Al=0 on the DC scan makes scan 1 about 30% larger than the default Al=1, delaying the preview slightly on very slow links in exchange for a preview with no visible banding. Measure with your own audience’s connection distribution before committing.
For an existing baseline JPEG you do not want to re-encode (re-encoding is generationally lossy), jpegtran reorders the same coefficients losslessly:
# -copy none strips EXIF/ICC — omit if you need colour management preserved.
jpegtran -copy none -scans hero.scans -outfile hero-progressive.jpg hero-baseline.jpg
Step 2 — Generate Every Placeholder Variant at Build Time
One pass over the source image should emit the dominant colour, the LQIP data URI, and the BlurHash, all keyed into a manifest your templates read. Doing this at request time is a mistake — BlurHash encoding of a 4000 px source is 100 ms of CPU you do not want in a response path.
// build/placeholders.mjs — run once per asset, cache the result by content hash.
import sharp from 'sharp';
import { encode as encodeBlurHash } from 'blurhash';
import { writeFile, readdir } from 'node:fs/promises';
import path from 'node:path';
async function derivePlaceholders(file) {
const img = sharp(file, { failOn: 'error' });
const { width, height } = await img.metadata();
// 1. Dominant colour. sharp's stats() returns the modal colour of a
// quantised histogram — more representative than a mean, which
// muddies to grey on high-contrast images.
const { dominant } = await img.stats();
const hex = '#' + [dominant.r, dominant.g, dominant.b]
.map(c => c.toString(16).padStart(2, '0')).join('');
// 2. LQIP. 32 px on the long edge; quality 20 is well past the point
// where artefacts matter because we blur it 20 px on the client.
const lqipBuf = await sharp(file)
.resize(32, 32, { fit: 'inside' })
.webp({ quality: 20, effort: 6, smartSubsample: true })
.toBuffer();
const lqip = `data:image/webp;base64,${lqipBuf.toString('base64')}`;
// 3. BlurHash needs raw RGBA. Downsample first — encoding at full
// resolution costs ~100 ms and produces an identical hash.
const { data, info } = await sharp(file)
.raw().ensureAlpha()
.resize(64, 64, { fit: 'inside' })
.toBuffer({ resolveWithObject: true });
// Components trade detail for length: 4x3 = 28 chars, 6x4 = 44, 9x9 = 130.
// Bias components toward the longer axis so the hash is not stretched.
const ratio = width / height;
const cx = ratio > 1.4 ? 5 : 4;
const cy = ratio < 0.7 ? 5 : 3;
const blurhash = encodeBlurHash(
new Uint8ClampedArray(data), info.width, info.height, cx, cy
);
return {
width, height,
aspect: +(width / height).toFixed(4),
hex, lqip, blurhash,
lqipBytes: lqip.length // budget this: see the tradeoffs section
};
}
const dir = process.argv[2] ?? 'src/images';
const manifest = {};
for (const name of await readdir(dir)) {
if (!/\.(jpe?g|png|tiff?)$/i.test(name)) continue;
manifest[name] = await derivePlaceholders(path.join(dir, name));
}
await writeFile('src/_data/placeholders.json', JSON.stringify(manifest, null, 2));
console.log(`wrote ${Object.keys(manifest).length} placeholder records`);
The BlurHash side of this — component tuning, colour-space caveats, and integrating the encode into a watch-mode build — is covered in depth in generating BlurHash placeholders with sharp.
Step 3 — Reserve the Box Before Anything Paints
This step is the entire CLS story. The placeholder and the final image must occupy a box whose geometry is known from the HTML alone, before either has loaded. Two mechanisms give you that: width/height attributes on the <img> (from which the browser computes a default aspect-ratio), or an explicit aspect-ratio on a wrapper.
<!-- The wrapper carries the placeholder as a *background*, so the placeholder is
never an element of its own and can never be an LCP candidate.
--ph-color and --ph-lqip come from the build manifest. -->
<div class="media" style="--ph-color:#3f5b46; --ph-lqip:url('data:image/webp;base64,UklGRhwA…'); aspect-ratio:16/9;">
<img
src="/hero-1600.avif"
srcset="/hero-800.avif 800w, /hero-1600.avif 1600w, /hero-2400.avif 2400w"
sizes="(max-width: 800px) 100vw, 1200px"
width="1600" height="900"
fetchpriority="high"
decoding="async"
alt="Sunrise over the harbour terminal"
onload="this.closest('.media').dataset.loaded='true'"
>
</div>
.media {
position: relative;
/* Dominant colour paints in the very first frame after CSS parses —
no image decode required, so it is the fastest possible non-empty state. */
background-color: var(--ph-color, #888);
background-image: var(--ph-lqip);
background-size: cover;
background-position: center;
overflow: hidden; /* clip the blur's soft edge */
/* filter: blur() on the wrapper would blur the real image too. Use a
pseudo-element so the blur applies only to the placeholder layer. */
}
.media::after {
content: '';
position: absolute;
inset: -6%; /* over-inset so the blur has no transparent border */
background: inherit;
filter: blur(18px);
transform: scale(1.06); /* hides the blur's edge falloff */
}
.media > img {
position: relative;
z-index: 1; /* sit above the ::after blur layer */
display: block;
width: 100%;
height: auto;
opacity: 0;
transition: opacity 260ms ease-out;
}
.media[data-loaded='true'] > img { opacity: 1; }
/* No-JS / onload-blocked fallback: never leave the image invisible. */
@media (scripting: none) { .media > img { opacity: 1; transition: none; } }
Because both layers live in the same aspect-ratio box, swapping them changes no geometry and produces a layout shift score of exactly zero. The failure mode — and it is by far the most common one — is omitting width/height so that the placeholder’s intrinsic 32×18 size drives layout until the photo arrives.
Step 4 — Swap on decode(), Not on load
The onload attribute in step 3 is the simple version, and it has a flaw: load fires when the bytes are decoded into the image cache, but on a large AVIF the rasterisation into the compositor can still cost 40 ms of main-thread work after that. Cross-fading at load therefore animates into a frame that has not been produced yet, producing a visible stutter. HTMLImageElement.decode() resolves only once the frame is ready to paint synchronously.
// swap.js — progressive enhancement over the CSS in step 3.
// Runs after the placeholder is already visible, so a failure here is invisible.
const swap = async (img) => {
const box = img.closest('.media');
try {
// decode() resolves when the image is rasterised and paintable.
// On a cached image it resolves in the same task, so there is no flash.
if (img.decode) await img.decode();
} catch {
// Aborted decodes (src swapped mid-flight) and broken images land here.
// Show the img anyway — a broken-image icon beats an eternal blur.
}
box.dataset.loaded = 'true';
// Drop the placeholder bytes from the layer tree once the fade is done,
// otherwise every .media keeps a blurred composited layer alive forever.
setTimeout(() => {
box.style.removeProperty('--ph-lqip');
box.style.backgroundImage = 'none';
}, 320); // must exceed the 260 ms CSS transition
};
for (const img of document.querySelectorAll('.media > img')) {
// complete === true covers images already in the HTTP cache at parse time.
if (img.complete && img.naturalWidth > 0) swap(img);
else img.addEventListener('load', () => swap(img), { once: true });
img.addEventListener('error', () => { swap(img); }, { once: true });
}
Warning: never gate the initial visibility of the real image on JavaScript. If your CSS ships opacity: 0 and the swap script fails to load, the page renders a blurred smudge with no way out. The @media (scripting: none) rule in step 3 covers the no-JS case; add a <noscript> style override too if you support browsers where the media query is unreliable.
Step 5 — Make Placeholders and Lazy Loading Agree
Placeholders and native lazy loading interact in a way that is easy to get backwards. loading="lazy" defers the fetch until the element is within the browser’s load-distance threshold — roughly 1 250 px on fast connections, up to 2 500 px on slow ones. Until then the box holds only what CSS gives it. That means a lazy image without a placeholder shows an empty box for the entire scroll approach, and a lazy image with a placeholder is the only construction where blur-up genuinely earns its bytes at scale.
The corollary is that the placeholder budget should scale inversely with distance from the viewport:
<!-- Above the fold: high-fidelity LQIP, eager, high priority.
The 640-byte data URI is worth it for exactly one element. -->
<div class="media" style="aspect-ratio:16/9; --ph-color:#3f5b46; --ph-lqip:url('data:image/webp;base64,UklGRhwA…')">
<img src="/hero-1600.avif" width="1600" height="900" alt="…"
fetchpriority="high" decoding="async" loading="eager">
</div>
<!-- Below the fold: dominant colour only — 4 bytes, no decode, no HTML bloat.
loading="lazy" means the fetch does not compete with the hero.
fetchpriority="low" further depresses it if it is fetched early anyway. -->
<div class="media" style="aspect-ratio:4/3; --ph-color:#7a6455">
<img src="/gallery-08.avif" width="800" height="600" alt="…"
loading="lazy" fetchpriority="low" decoding="async">
</div>
<!-- Far below the fold: add content-visibility so the browser skips
layout and paint entirely until the section approaches the viewport.
contain-intrinsic-size supplies a placeholder geometry so the
scrollbar does not jump — the CLS analogue of width/height. -->
<section style="content-visibility:auto; contain-intrinsic-size:auto 900px">
<!-- 40 more .media blocks, dominant colour only -->
</section>
For galleries where you want the placeholder to upgrade to a higher-fidelity preview shortly before the real image is requested, an IntersectionObserver with a staged rootMargin is the right tool — the patterns in advanced IntersectionObserver patterns for media apply directly, using the outer margin to inject the LQIP and the inner one to set src.
Step 6 — Wire It Into the Delivery Layer
Two delivery-layer details finish the job. First, the placeholder data lives in the HTML, so the HTML’s cacheability now affects placeholder freshness — if you fingerprint image URLs but serve HTML with a long max-age, a re-cropped image will show yesterday’s blur. Second, progressive JPEG only helps if bytes actually stream; a proxy that buffers the whole response before forwarding it destroys every early scan.
# 1. Confirm the origin streams rather than buffers. If the transfer shows a
# single burst at the end, an intermediary is buffering and progressive
# rendering is dead on arrival regardless of the encode.
curl -o /dev/null -w 'ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
--limit-rate 200k https://example.com/hero-progressive.jpg
# 2. Fetch only the first 12% of the file and confirm a decoder can render it.
# A progressive file yields a full-frame image; a baseline file yields
# a top strip and a "premature end of data segment" warning.
curl -s -r 0-24575 https://example.com/hero-progressive.jpg -o partial.jpg
djpeg -outfile partial.png partial.jpg # inspect partial.png visually
# 3. Ensure image URLs are content-addressed so HTML-embedded placeholders
# and their images can never drift apart.
curl -sI https://example.com/hero-1600.avif | grep -iE 'cache-control|etag'
Serve the images themselves with Cache-Control: public, max-age=31536000, immutable against a hashed filename, and keep the HTML on a short TTL with revalidation — the full reasoning is in Cache-Control headers for image and video assets.
Parameter Reference
| Parameter | Where | Values | Effect |
|---|---|---|---|
-progressive |
cjpeg / jpegtran |
flag | Applies libjpeg’s default 10-scan script |
-scans <file> |
cjpeg / jpegtran |
path | Custom scan script; implies progressive |
Ss / Se |
scan script | 0–63 | First/last DCT coefficient in the scan (spectral selection) |
Ah / Al |
scan script | 0–13 | Successive-approximation high/low bit; Al=0 means full precision |
progressive |
sharp .jpeg({}) |
boolean | Default libjpeg script only — sharp exposes no scan-script hook |
mozjpeg |
sharp .jpeg({}) |
boolean | Enables mozjpeg’s trellis quantisation and progressive default |
--progressive |
avifenc |
flag | Emits a layered AVIF (a1lx); +10–20% bytes, max 4 layers |
componentX / componentY |
BlurHash encode() |
1–9 | Detail per axis; 4×3 → 28 chars, 6×4 → 44, 9×9 → 130 |
punch |
BlurHash decode() |
0.5–2 | Contrast multiplier applied at decode; >1 exaggerates saturation |
-n |
primitive (SQIP) |
8–20 | Shape count; each shape is roughly 60–90 bytes of SVG |
-m |
primitive (SQIP) |
0–8 | Shape mode; 1 (triangle) and 5 (ellipse) compress best |
aspect-ratio |
CSS | w / h |
Reserves the box before any image metadata is known |
contain-intrinsic-size |
CSS | auto <len> |
Supplies geometry for content-visibility:auto subtrees |
decoding |
<img> |
async / sync / auto |
async keeps decode off the critical path; sync blocks paint |
loading |
<img> |
lazy / eager |
lazy defers fetch; the placeholder is what fills the gap |
Tradeoffs & Edge Cases
Tradeoff: progressive JPEG is larger below ~10 KB. Each scan carries its own Huffman table overhead, so for thumbnails and icons the multi-scan layout costs 5–15% more bytes than baseline while delivering a preview nobody will ever see — the file arrives in one packet anyway. Gate the progressive flag on source dimensions: apply it above roughly 200×200 or 10 KB output, and force baseline below that. Most build pipelines set progressive: true globally and quietly inflate every thumbnail on the site.
Tradeoff: the decode cost lands on the device least able to pay it. The 81 ms progressive decode in the benchmark table is on a mid-range Android; the same file is 28 ms on a desktop. Progressive rendering helps most on slow networks, and hurts most on slow CPUs — and those two populations overlap heavily. If your Interaction to Next Paint budget is tight and images decode during a scroll, progressive JPEG can be the wrong trade even though its first-pixels number looks better.
Warning: inline placeholder bytes are on the critical path, and they multiply. A 60-item product grid with a 640-byte LQIP each adds 38 KB to the HTML document — pre-compression, and base64 compresses poorly because it is already high-entropy. That delays the closing </head>, the CSS, and therefore the hero image itself. The rule that holds up in production: LQIP or BlurHash for at most the first 2–3 images, dominant colour for everything else. A useful budget is 2 KB of total placeholder payload per document.
Edge case: background-image placeholders can themselves become the LCP element. The safety in step 3 comes from the low-entropy heuristic, not from being a background — CSS background images are LCP candidates. If you ever raise the LQIP resolution to, say, 96×54 at quality 60 (about 3 KB), recompute the bits-per-pixel: 3000 × 8 / (1200 × 675) = 0.030 bpp, still under the threshold but only by a factor of 1.7. At 128×72 quality 70 you cross it, and your LCP suddenly reports the blur. Anything you inline should stay two orders of magnitude below 0.05 bpp.
Edge case: the CLS-safe box does not protect you from object-fit mismatches. Reserving aspect-ratio: 16/9 while the real image is 3:2 does not shift layout — but the swap visibly jumps the content inside the box as object-fit: cover recrops. Store the true aspect ratio in the placeholder manifest (step 2 emits it) and set the box from that value rather than a hardcoded constant. This is the same failure that makes fill layouts tricky in framework components; see fixing CLS with the Next.js Image fill and sizes props for the component-level version, and preventing CLS when swapping a placeholder for the real image for the framework-agnostic diagnosis.
Tradeoff: BlurHash’s client decode is main-thread work at exactly the wrong moment. Decoding a 4×3 hash to a 32×32 canvas is roughly 1–2 ms; decoding thirty of them during initial parse is 30–60 ms of blocking work competing with the hero image’s own decode. Either decode lazily as elements approach the viewport, or precompute the hash’s output into a CSS linear-gradient at build time and skip the runtime decode entirely. Dominant colour has no such cost, which is the main reason it remains the default choice for long lists.
Debugging & Validation
Confirm the Encoded Scan Structure
# List every scan with its Ss/Se/Ah/Al parameters. Doubling -verbose
# prints per-scan detail; a baseline file prints exactly one scan.
djpeg -verbose -verbose -outfile /dev/null hero.jpg 2>&1 | grep -iE 'scan|progressive'
# ImageMagick reports the interlace mode: "JPEG" means progressive,
# "None" means baseline. Works on any format ImageMagick reads.
identify -format '%f interlace=%[interlace] %wx%h %B bytes\n' hero.jpg
# exiftool reports the codestream process directly.
exiftool -EncodingProcess -ProgressiveScans hero.jpg
# For AVIF, check whether the file is layered. A progressive AVIF prints
# more than one layer and carries the a1lx property.
avifdec --info hero.avif 2>&1 | grep -iE 'layer|a1lx|progressive'
Verify Placeholder Entropy Against the LCP Threshold
// Paste into DevTools Console. Flags any placeholder that is close enough
// to 0.05 bpp to risk being adopted as the LCP candidate.
document.querySelectorAll('.media').forEach(box => {
const url = getComputedStyle(box).backgroundImage.match(/base64,([^"')]+)/);
if (!url) return;
const bytes = Math.floor(url[1].length * 3 / 4); // base64 → raw bytes
const r = box.getBoundingClientRect();
const bpp = (bytes * 8) / (r.width * r.height);
console.log(box.dataset.name ?? box, {
bytes,
painted: `${Math.round(r.width)}×${Math.round(r.height)}`,
bpp: bpp.toFixed(4),
verdict: bpp > 0.05 ? 'LCP CANDIDATE — reduce placeholder' : 'excluded (safe)'
});
});
Attribute LCP and Catch the Swap Shift
// LCP: log the element and URL so you can see whether the placeholder or
// the photo was credited. buffered:true captures entries fired before this ran.
new PerformanceObserver(list => {
for (const e of list.getEntries()) {
console.log('[LCP]', Math.round(e.renderTime || e.loadTime), 'ms',
{ url: e.url || '(text)', size: e.size, el: e.element });
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
// CLS: print each shift with its sources, which names the exact node that
// moved. hadRecentInput filters out shifts excluded from the metric.
new PerformanceObserver(list => {
for (const e of list.getEntries()) {
if (e.hadRecentInput) continue;
console.log('[shift]', e.value.toFixed(4), 'at', Math.round(e.startTime), 'ms',
e.sources.map(s => ({ node: s.node, from: s.previousRect, to: s.currentRect })));
}
}).observe({ type: 'layout-shift', buffered: true });
Reproduce the Slow-Network Case Deterministically
Throttling in DevTools is convenient but noisy. For a repeatable filmstrip, serve the asset through a rate-limited proxy and capture frames:
# Rate-limit a local origin to 200 kbps so scan boundaries are visible.
# Then capture a filmstrip and diff it against the previous build.
npx webpagetest test https://example.com/ \
--location "Dulles:Chrome" --connectivity 3GSlow --runs 3 --video \
--label "progressive-hero" --poll 5 --timeout 300
# Lighthouse for the metric-level regression check.
lighthouse https://example.com/ \
--only-audits=largest-contentful-paint,cumulative-layout-shift,uses-responsive-images \
--throttling-method=simulate --output=json --output-path=./lh.json
Compare the filmstrip frames rather than the numbers when you are validating a placeholder change — the point of the exercise is a visual state that no metric captures. For the metric-level counterpart, keep an eye on field data through the CrUX API: a placeholder change that improves the filmstrip while degrading p75 LCP means the inline payload has grown past its budget. If you need the raw decode-time comparison behind the benchmark table, it is reproduced step by step in progressive JPEG vs baseline decode timing.
Related
- Generating BlurHash placeholders with sharp — component tuning, build-time caching, and the decode path on the client
- Progressive JPEG vs baseline decode timing — reproduce the scan-by-scan timings with djpeg and a throttled origin
- Preventing CLS when swapping a placeholder for the real image — reserve geometry correctly and diagnose the shift sources that survive
- AVIF vs WebP compression benchmarks — the file-size case for the formats that gave up in-format progressive decode
- Using fetchpriority to optimize critical media — get the real image started earlier so the placeholder is on screen for less time
- Mastering srcset and sizes for responsive layouts — pick the candidate whose painted size your placeholder’s box must match
- Core Media Fundamentals & Next-Gen Formats — parent section covering codec selection, MIME configuration, and cache policy