01 — Formats: pick before you compress

The whole lesson runs one scenario: a page with 20 images and a 1.0MB initial-load budget. Format, quality, srcset, lazy — every choice has to add up under that ceiling.

Four formats matter in 2026:

  • AVIF — AV1-derived, roughly half the bytes of JPEG at matched perceptual quality. ~95% Baseline 2024 support (Safari shipped 16.4 in March 2023). Slow to encode, so most teams use a CDN or build step.
  • WebP — ~2530% smaller than JPEG. Effectively 100% support since 2020 — use it as the AVIF fallback, not the headline format.
  • JPEG — universal since 1992. Keep it as the last branch in your <picture>.
  • PNG — lossless, alpha. Excellent for UI, terrible for photographs (510× the JPEG).

The shipping pattern is <picture> with sources from best to worst, then a JPEG fallback:

HTML
1<picture>
2 <source type="image/avif" srcset="hero.avif" />
3 <source type="image/webp" srcset="hero.webp" />
4 <img src="hero.jpg" alt="..." width="1600" height="1000" />
5</picture>

The browser walks <source> top to bottom and uses the first match. The <img> at the end is both the rendering target and the universal safety net.

Two attributes on the <img> that you should never skip: width and height. Even when the image is responsive, those numbers (or a CSS aspect-ratio) let the browser reserve the layout box before bytes arrive — your insurance policy against Cumulative Layout Shift.

Look at the lab on the right: the format comparison bars show the gap visually.

02 — Quality: the steep early curve

Quality vs file size is non-linear. Most of the savings live in the first 10 quality points; the diminishing-returns curve gets brutal below q=70.

The sweet spots by content type:

  • Photographs (sunsets, faces, scenery): q=80 to q=85.
  • Screenshots, UI, text-heavy images: q=90 or higher — text artefacts read as broken software.
  • Hero/marketing imagery: don't lower quality, switch format — q=85 AVIF beats q=70 JPEG on both bytes and clarity.

One non-obvious thing AVIF buys you that the bars in the lab don't show: you can raise the quality setting on every image and still come in under budget, because AVIF's per-pixel cost is half of JPEG's. Treat the format upgrade as bandwidth you can spend on visual quality.

Drag the quality dial on the right to watch the JPEG/WebP/AVIF bars collapse and stretch together. The relative gap is constant across the curve — that's the AVIF dividend.

03 — srcset: build the markup

A 1600px-wide hero is ~148KB. A 400px phone at dpr=3 only needs 1200 real pixels. Everything above that is bandwidth the browser will throw away — it downscales at render time and discards the extra data.

srcset and sizes together let the browser pick the smallest source that still covers the displayed pixels. The mental model the browser uses:

  1. Read sizes to figure out how wide the image will render in CSS pixels at this viewport.
  2. Multiply by devicePixelRatio to get the effective width in device pixels.
  3. Pick the smallest srcset candidate that is ≥ effective width.
HTML
1<img
2 srcset="hero-480.webp 480w,
3 hero-800.webp 800w,
4 hero-1200.webp 1200w,
5 hero-1600.webp 1600w"
6 sizes="(min-width: 768px) 50vw, 100vw"
7 src="hero-800.webp"
8 width="1600" height="1000" alt="..." />

For fixed-size images like avatars or icons, skip w descriptors and use x descriptors — the math is obvious:

HTML
1<img src="avatar.webp"
2 srcset="avatar.webp 1x, [email protected] 2x, [email protected] 3x"
3 width="40" height="40" alt="Ada" />

Real device pixel ratios in 2026: phones are dpr=3 (most flagships since 2018), retina laptops are dpr=2, standard desktops are dpr=1. The builder on the right lets you toggle DPR — the picker re-runs and you can see which candidate the browser would pick. The emitted HTML is yours to copy.

04 — Art direction: different crops, not different sizes

srcset resizes the same image. Art direction ships a different crop entirely — a wide landscape on desktop, a portrait crop on phones, so the subject is never reduced to a thin strip.

This is the <picture> element doing real work — each <source> has a media query, and the browser walks them top to bottom:

HTML
1<picture>
2 <source media="(max-width: 480px)" srcset="hero-portrait.avif" type="image/avif" />
3 <source media="(max-width: 480px)" srcset="hero-portrait.webp" type="image/webp" />
4 <source media="(max-width: 1024px)" srcset="hero-square.avif" type="image/avif" />
5 <source media="(max-width: 1024px)" srcset="hero-square.webp" type="image/webp" />
6 <source srcset="hero-landscape.avif" type="image/avif" />
7 <source srcset="hero-landscape.webp" type="image/webp" />
8 <img src="hero-landscape.jpg" alt="..." width="1600" height="900" />
9</picture>

The order matters: breakpoint first, then format within breakpoint. The browser uses the first <source> whose media and type both match — anything after that is ignored.

This is design work first, optimisation second. The art director picks which crop tells the story at each breakpoint; srcset then picks the right size within that crop. Drag the viewport on the right to see which <source> wins at each width.

05 — Lazy loading is a budget decision

A page with 20 images doesn't have to ship 20 image requests on the first paint. Mark each image eager or lazy and the total has to fit under 1.0MB.

The minimum-viable markup:

HTML
1<img src="hero.avif"
2 width="1600" height="1000"
3 fetchpriority="high"
4 decoding="async"
5 alt="hero" />
6
7<img src="card.webp"
8 width="320" height="240"
9 loading="lazy"
10 decoding="async"
11 alt="card" />

Two attributes do most of the work:

  • loading="lazy" defers the network request until the image approaches the viewport. Native. No JS. No callback.
  • width and height (or a CSS aspect-ratio) reserve the layout box before bytes arrive — that's your CLS insurance policy.

When to reach for IntersectionObserver instead

Native loading="lazy" is the default. Use a hand-rolled IntersectionObserver only when you need something the native API doesn't give you:

JS
1const io = new IntersectionObserver((entries) => {
2 for (const entry of entries) {
3 if (entry.isIntersecting) {
4 const img = entry.target;
5 img.src = img.dataset.src;
6 io.unobserve(img);
7 }
8 }
9}, { rootMargin: "200px 0px" });
10
11document.querySelectorAll("img[data-src]").forEach((img) => io.observe(img));

Reach for IO when you need: a placeholder fade-in, a per-image counter, a tighter or looser rootMargin, or analytics callbacks. For everything else, loading="lazy" is free.

The background-image gotcha

loading="lazy" only works on <img> and <iframe>. A CSS background-image is not subject to native lazy loading — the browser fetches it the moment the element matches any selector with that rule. If you need to defer a hero background, either move to an <img> with positioning or hand-roll IntersectionObserver and toggle the class.

Toggle eager/lazy on the gallery in the lab. Watch the meter — red means you blew the budget. The hero ribbon flips a different warning when image #1 is lazy.

06 — Priority hints: the modern hero incantation

The browser sorts every network request by priority. Out of the box, it gives <img> requests “low” priority until layout determines they're in the viewport — then bumps them to “high”. The bump happens after layout, which is too late if the LCP image is the largest above-the-fold element.

fetchpriority="high" tells the browser this resource is LCP-critical before layout runs. Pair it with decoding="async" — free since Chrome 65 — and you have the 2024+ hero incantation:

HTML
1<img src="hero.avif"
2 fetchpriority="high"
3 decoding="async"
4 width="1600" height="1000"
5 alt="hero" />

fetchpriority supersedes <link rel="preload"> for hero images

Before fetchpriority shipped (Chrome 101 in 2022, Safari 17.2 in 2023), the only way to start the LCP image early was a preload tag:

HTML
1<link rel="preload" as="image"
2 imagesrcset="hero-800.avif 800w, hero-1600.avif 1600w"
3 imagesizes="100vw" />

That still works, but it has problems: easy to typo, easy to mismatch the <img>'s srcset (causing a double-fetch), easy to forget when the markup changes. Use <link rel="preload" as="image"> only when the image is loaded by JavaScript after parse (e.g., a user-triggered carousel) and the browser can't see it during the preload-scanner pass.

And fetchpriority="low" for the decorative stuff

The mirror move: tell the browser which images don't matter, so they stop competing with the LCP image for bandwidth:

HTML
1<img src="avatar.webp" fetchpriority="low" loading="lazy" alt="commenter" />
2<img src="bg-pattern.svg" fetchpriority="low" alt="" role="presentation" />

Background patterns, below-fold avatars, decoration that's nice-to-have but isn't holding up the experience. The waterfall on the right shows what changes when you toggle priority hints on.

07 — Image CDN: one URL, many bytes

The story so far has been what to ship. An image CDN handles how to derive it. You keep one master image at the origin; the CDN generates every variant — resized, recompressed, re-encoded — at the edge.

The flow:

  1. The browser sends Accept: image/avif, image/webp, image/*.
  2. The CDN reads that header plus URL parameters (?w=1600&q=80&fmt=auto) and looks up the cache key (URL, Accept, dimensions).
  3. On a cache hit, the edge node responds immediately. On a miss, it fetches the master from origin, applies the transforms, caches the result, and responds.

The same URL/cdn/hero.jpg?w=1600&q=80&fmt=auto — returns different bytes to different clients:

  • Chrome Content-Type: image/avif, ~68KB
  • Safari 16 Content-Type: image/webp, ~84KB
  • IE 11 (still alive on kiosks) Content-Type: image/jpeg, ~115KB

Click through the pipeline in the lab to see each stage.

The vendors you'll meet:

  • Image CDNs: Cloudinary, imgix, Cloudflare Images, Fastly Image Optimizer, AWS CloudFront with Lambda@Edge.
  • Framework wrappers: Next.js <Image>, Astro <Image>, Nuxt <NuxtImg> — they wrap an underlying CDN and emit srcset/sizes/width/height automatically.
  • Self-hosted: thumbor or imageproxy when you cannot send images off your network.

One operational detail: most CDNs sign their transform URLs or restrict allowed values, because every unique URL becomes a cache entry. An attacker who can request arbitrary ?w= values can churn your cache and rack up transform costs. Always lock down the allowed widths and qualities at the edge.

FmQlSsAdLzPrCd
ScenarioOne page · 20 images · initial-load budget 1.0 MB
Format comparison · quality 80
FormatLossyAlphaAnimSupp.BytesSize
The picture-element fallback
<picture>
  <source type="image/avif" srcset="hero.avif" />
  <source type="image/webp" srcset="hero.webp" />
  <img src="hero.jpg" alt="..." width="1600" height="1000" />
</picture>
Browsers walk the <source> list top-to-bottom; the first matching type wins. Always end on a JPEG <img>— that branch survives even on devices you didn't anticipate.