Lesson 5 of 9 · 24m
Image Strategy: Formats, Sizing, and the LCP Image
Images are usually the LCP element and usually the biggest bytes. Responsive sizing, modern formats, priority hints, and the lazy-loading mistake that slows down half the web.
On most content and commerce pages, the LCP element is an image — and on most of those pages, that image is wrong in at least one of three ways: it's discovered late, it's far bigger than its display size, or it's served in a format from 1992. Fix all three on the hero and you'll often take a full second off LCP without touching a line of application code.
Rule one: the LCP image must be discoverable and prioritized
The browser's preload scanner finds <img> tags in the raw HTML almost immediately. It cannot find images set by CSS background-image, injected by JavaScript, or rendered client-side after hydration. Those all show up as huge resource load delay.
<img
src="/img/trail-runner-3-1200.avif"
srcset="/img/trail-runner-3-640.avif 640w,
/img/trail-runner-3-960.avif 960w,
/img/trail-runner-3-1200.avif 1200w,
/img/trail-runner-3-1800.avif 1800w"
sizes="(min-width: 1024px) 50vw, 100vw"
width="1200" height="900"
fetchpriority="high"
alt="Trail Runner 3 in slate blue, side view">fetchpriority="high" tells the browser this image outranks the other images it's found; by default images start at low priority until layout proves they're in the viewport. If the hero truly can't be in the HTML (it's chosen by JS), preload it from the head with <link rel="preload" as="image" imagesrcset="..." imagesizes="..." fetchpriority="high">.
Rule two: never lazy-load the LCP image
loading="lazy" delays the request until layout confirms the image is near the viewport — which means after CSS has loaded and layout has run. On the hero, that's pure added delay. This is the single most common LCP regression I see, usually from a component library that defaults every <Image> to lazy. Lazy-load everything below the fold; eagerly load, and prioritize, the one image that is the LCP.
Rule three: serve the right bytes
- Size:
srcsetwithwdescriptors plus an honestsizeslets the browser pick the smallest file that covers the slot at the device's pixel ratio. A wrongsizes(or none — it defaults to100vw) quietly makes phones download desktop images. - Format: AVIF is typically 30–50% smaller than JPEG at the same perceived quality; WebP 25–30%. Both are supported by all current browsers. Use
<picture>with<source type="image/avif">if you still need a JPEG fallback. - Quality: AVIF at quality ~50 and WebP at ~75 look fine for photos. Nobody can see the difference between q85 and q95 on a phone; everyone can feel the extra 300 KB.
A build step generates the variants once, rather than trusting editors to export correctly:
// scripts/build-images.mjs — run at build time with `node scripts/build-images.mjs`
import sharp from "sharp";
const widths = [640, 960, 1200, 1800];
for (const w of widths) {
await sharp("src/img/trail-runner-3.jpg")
.resize({ width: w, withoutEnlargement: true })
.avif({ quality: 50 })
.toFile(`public/img/trail-runner-3-${w}.avif`);
}Dimensions prevent layout shift
Always set width and height attributes (or CSS aspect-ratio). The browser uses the ratio to reserve space before a single byte arrives, so the content below doesn't jump when the image loads. Missing dimensions on images are still the most common CLS cause on the web.
Field notes
- Check the LCP element in the field data, per device. A carousel's first slide, a text heading, and a video poster each need different fixes.
decoding="async"is fine for below-fold images; on the LCP image it can occasionally delay paint by a frame. Measure before sprinkling it everywhere.- An image CDN (resize and format negotiation on the fly via the
Acceptheader) beats a build step once you have user-uploaded content — make sure it sendsVary: Accept. - A fade-in animation on the hero image delays the LCP timestamp until the image is painted, and some animations push it later. Designers should know the trade.