Responsive Images & Performance
srcset, sizes, picture, and the image-loading techniques that keep a real page fast on real connections.
Why a Single Image File Is Almost Always Wrong
A single <img src="..."> points at exactly one file, and that one file has to serve every device that requests it — a laptop with a 1x display sitting on a fast office connection, a five-year-old Android phone on a throttled mobile connection, and a 3x-density iPhone in the same browsing session. If the image is sized and compressed for the laptop, the phone downloads far more data than its small screen can even display. If it is sized for the phone, the laptop shows a soft, upscaled image. There has never been one correct file size for every device — which is exactly the problem srcset, sizes, and <picture> exist to solve.
It helps to separate two distinct problems that get conflated constantly, because the browser solves them with two different tools:
1. RESOLUTION SWITCHING
Same crop, same content, different FILE SIZES for different screen
densities and viewport widths. Tool: srcset + sizes on a single <img>.
2. ART DIRECTION
Genuinely different CROPS or compositions for different screen sizes —
a tight portrait crop on mobile, a wide landscape crop on desktop.
Tool: <picture> with multiple <source> elements.This module covers both in depth, then layers on the format question (WebP/AVIF vs JPEG/PNG) and the two Core Web Vitals metrics — CLS and LCP — that images are the single most common cause of failing.
img, alt, width/height, and a first pass at loading="lazy" were covered. Everything here builds directly on that foundation rather than repeating it.Resolution Switching — Serving the Right File Size Automatically
srcset gives the browser a list of candidate image files, each labeled with either its pixel width or its pixel density, and lets the browser — not your CSS, not JavaScript — choose which one to actually download, based on the real device it is running on and the real size the image will render at.
Density descriptors — the simple case
The simplest form of srcset offers the same image at 1x, 2x, and sometimes 3x pixel density, for a fixed display size (a logo or an icon that always renders at a known CSS size regardless of viewport).
<img
src="/logo-200.png"
srcset="/logo-200.png 1x, /logo-400.png 2x, /logo-600.png 3x"
alt="Chaduvuko logo"
width="200"
height="60"
>
<!-- A standard 1x display downloads logo-200.png.
A Retina-class 2x display downloads logo-400.png.
A 3x phone display downloads logo-600.png.
The browser measures its own pixel density and picks automatically. -->Width descriptors — the case that actually matters for real content images
Density descriptors only work when the image renders at a fixed, known CSS size. Most real content images — a blog hero, a product photo, a card thumbnail — render at a size that changes with the viewport (full-width on mobile, a third of the width on desktop). For those, srcset uses width descriptors instead — labeling each candidate with its actual intrinsic pixel width using w, not a density multiplier.
<img
src="/blog/hero-800.jpg"
srcset="
/blog/hero-400.jpg 400w,
/blog/hero-800.jpg 800w,
/blog/hero-1200.jpg 1200w,
/blog/hero-1600.jpg 1600w
"
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 800px"
alt="Team standup meeting around a whiteboard"
width="1200"
height="675"
>Width descriptors alone are not enough — the browser also needs to know how large the image is actually going to be rendered, in CSS pixels, at the current viewport width, before it can pick the smallest candidate that is still large enough. That is exactly what sizes supplies.
sizes — telling the browser the rendered width, before it downloads anything
sizes is a comma-separated list of media conditions paired with a rendered width, evaluated top to bottom — the browser uses the first condition that matches the current viewport. The final, condition-less value is the fallback for anything that did not match.
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 800px"
<!-- Read as three rules, checked in order:
1. If the viewport is 600px or narrower, this image renders at 100% of
the viewport width (100vw) — a full-bleed mobile layout.
2. Else, if the viewport is 1000px or narrower, it renders at 50% of
the viewport width — a two-column tablet layout.
3. Otherwise (desktop), it renders at a fixed 800px — the image never
grows past 800px wide regardless of how wide the screen gets. -->Combining these two attributes, the browser's decision process is: read sizes to work out how many CSS pixels the image will actually occupy at the current viewport width, multiply that by the screen's pixel density, then pick the smallest candidate from srcset that is still large enough to cover that many real pixels without visibly blurring. It downloads exactly one file — never all of them, and this decision happens before a single byte of any candidate is fetched.
100vw — full viewport width — which is very often wrong, and leads to the browser downloading a candidate that is either needlessly large or, worse, too small and visibly soft. Every srcset that uses w descriptors needs a matching sizes attribute that actually reflects your real CSS layout.The src attribute is still required alongside srcset — it is the fallback used by any browser old enough not to understand srcset at all, and it is also what a browser lazily evaluating the DOM without CSS applied falls back to. Set it to a reasonable mid-size candidate, never the largest file in the set.
picture and source — True Art Direction, Not Just Resolution Switching
srcset only ever serves the same image at different sizes — same crop, same composition, just more or fewer pixels. Sometimes that is not what a responsive layout actually needs. A wide, cinematic hero photo that looks great spanning a 1600px desktop viewport often becomes an unreadable sliver of background noise when it is naively squeezed down into a 375px-wide mobile hero — the subject shrinks to nothing. What the mobile layout actually needs is a different crop: tighter, portrait-oriented, centered on the subject. That is art direction, and it is a job srcset cannot do — it is exactly what <picture> exists for.
<picture>
<source media="(max-width: 600px)" srcset="/hero/hero-mobile-crop.jpg">
<source media="(max-width: 1024px)" srcset="/hero/hero-tablet-crop.jpg">
<img src="/hero/hero-desktop-crop.jpg" alt="Founders reviewing quarterly roadmap on a whiteboard" width="1600" height="700">
</picture>The mechanics matter here: <picture> is a wrapper element with no rendering of its own. It contains any number of <source> elements followed by exactly one <img>, which is mandatory — not just as a fallback, but because <img> is what actually does the rendering, sizing, and lazy-loading. The browser evaluates each <source>'s media condition top to bottom and uses the first one that matches; if none match, it falls through to the plain <img>'s own src.
<picture> and <source> only ever influence which file gets requested; the <img> is still what the browser lays out, reserves space for, and exposes to assistive technology.Combining art direction with resolution switching in the same picture
The two techniques are not mutually exclusive — a real production hero image commonly needs both a different crop per breakpoint and multiple resolutions within each crop. Each <source> can carry its own full srcset/sizes pair.
<picture>
<source
media="(max-width: 600px)"
srcset="/hero/mobile-480.jpg 480w, /hero/mobile-960.jpg 960w"
sizes="100vw"
>
<source
media="(max-width: 1024px)"
srcset="/hero/tablet-800.jpg 800w, /hero/tablet-1600.jpg 1600w"
sizes="100vw"
>
<img
src="/hero/desktop-1600.jpg"
srcset="/hero/desktop-1600.jpg 1600w, /hero/desktop-2400.jpg 2400w"
sizes="1600px"
alt="Founders reviewing quarterly roadmap on a whiteboard"
width="1600"
height="700"
>
</picture>This is genuinely the most complete form the responsive-images system offers: for every breakpoint, the browser both selects the right crop and the right resolution within that crop, downloading exactly one file for the entire element.
WebP and AVIF vs JPEG and PNG — Choosing (and Falling Back On) a Format
File format is a second, independent axis of optimization, on top of everything srcset and sizes already handle. The same photograph, at the same pixel dimensions, can vary enormously in file size purely based on which compression format encoded it.
JPEG — the long-standing default for photos. Lossy, widely supported everywhere.
PNG — lossless, supports transparency. Best for graphics/icons/screenshots, not photos.
WebP — typically 25-35% smaller than an equivalent-quality JPEG. Supported in every
modern browser (Chrome, Firefox, Safari, Edge — all current versions).
AVIF — typically smaller again than WebP, sometimes significantly. Newer, slightly
less universally supported, and slower to encode.The practical answer is almost never "pick one format and use it everywhere" — it is "offer the most efficient format first, and fall back gracefully for anything that cannot decode it," using exactly the same <picture>/<source> mechanism from Part 03, this time keyed off type instead of media.
<picture>
<source srcset="/products/sneaker.avif" type="image/avif">
<source srcset="/products/sneaker.webp" type="image/webp">
<img src="/products/sneaker.jpg" alt="Red and white running sneaker, side profile" width="800" height="800">
</picture>The browser walks the <source> elements in order and uses the first one whose type it can actually decode — a browser that supports AVIF uses the first line and never even requests the WebP or JPEG files. A browser without AVIF support skips straight past it, tries WebP next, and so on down to the guaranteed universal JPEG fallback. Nothing here requires server-side browser detection or user-agent sniffing — the browser makes the decision itself, based on formats it actually knows how to decode.
<source> can carry its own type AND its own srcset/sizes — a real production image pipeline commonly generates an AVIF set, a WebP set, and a JPEG set, each at several widths, all wired into one <picture>. Tools like Next.js's built-in Image component, or a CDN-based image service, generate this entire matrix automatically from a single source file — hand-writing it is realistic for a handful of hero images, not for every image on a large site.loading="lazy" Revisited — And the Priority Attributes Around It
The Images and Media module introduced loading="lazy" as a preview. Here is the performance-focused version: loading is one of three attributes that control how aggressively the browser fetches and decodes an image relative to everything else competing for bandwidth on the page, and getting all three right — or wrong — has a direct, measurable effect on load performance.
loading="lazy" | "eager"
Whether the browser defers fetching until the image nears the viewport
("lazy") or fetches immediately regardless of position ("eager", the default).
fetchpriority="high" | "low" | "auto"
A hint about how urgently this specific request should be scheduled
relative to everything else the page is loading.
decoding="async" | "sync" | "auto"
Whether decoding the image (turning compressed bytes into pixels) is
allowed to happen off the main thread, without blocking other rendering.The rule that matters most in practice: never lazy-load the image that appears above the fold — especially a hero image, which is very often also the page's Largest Contentful Paint element (Part 07). Lazy-loading it actively delays the single metric it is most likely to be judged on, because the browser now waits for a scroll-proximity check that will never meaningfully change anything for an image already in the initial viewport.
<img
src="/hero/desktop-1600.jpg"
alt="Founders reviewing quarterly roadmap on a whiteboard"
width="1600"
height="700"
loading="eager"
fetchpriority="high"
>
<!-- Everything below the fold: the opposite treatment -->
<img
src="/blog/footer-illustration.png"
alt="Illustration of a rocket launching"
width="800"
height="400"
loading="lazy"
fetchpriority="low"
>Cumulative Layout Shift — Images Are the Most Common Cause
Cumulative Layout Shift (CLS) is one of Google's three Core Web Vitals, and it measures exactly one thing: how much visible content unexpectedly moves after it has already been painted to the screen. It is scored, not binary — every unexpected shift contributes a fraction to a running total for the page, based on how much of the viewport moved and how far it moved.
The Images and Media module already introduced the mechanism: an <img> with no width/height (and no equivalent CSS aspect-ratio) gives the browser zero information about how tall it will be before the file downloads. The browser renders the page with no space reserved, then — the instant the image's real dimensions become known — shoves everything below it downward. That sudden shove is precisely what CLS measures.
<!-- Causes layout shift: no dimensions, no reserved space -->
<img src="/blog/hero.jpg" alt="Solar panels on a hillside at sunset">
<!-- Fixed: width/height give the browser the aspect ratio immediately -->
<img
src="/blog/hero.jpg"
alt="Solar panels on a hillside at sunset"
width="1200"
height="675"
>It is worth being precise about what width/height actually give the browser: not a fixed pixel size, but a ratio. Modern browsers compute an implicit aspect-ratio from the two attributes and reserve exactly that shape of space, then let CSS control the final rendered size on top of it — which is why img { max-width: 100%; height: auto; } and explicit width/height attributes work together rather than conflicting.
Other common CLS sources images introduce, beyond a missing width/height
1. Web fonts swapping in after a font-based icon or a text label reflows
the layout around a nearby image — not the image itself, but often
diagnosed alongside it since both show up in the same DevTools report.
2. An ad slot or embed placeholder that has no reserved height and pops
in above or beside an image once its content loads asynchronously.Largest Contentful Paint — Why the Hero Image Is Usually the Bottleneck
Largest Contentful Paint (LCP) measures how long it takes the single largest visible element — on most real pages, this is a hero image, a large heading, or a background image — to finish rendering after the page starts loading. It is the Core Web Vital most directly tied to a user's felt sense of "is this page actually here yet," and on the overwhelming majority of content-heavy pages, the LCP element is an image.
An unoptimized hero image drags LCP down through a predictable, stacked sequence of delays — each one independently fixable with a technique already covered earlier in this module.
1. Oversized file — a 4000px-wide, unresized original JPEG serving a
1600px hero slot. Fix: srcset with correctly sized candidates (Part 02).
2. Wrong format — an uncompressed PNG for what is fundamentally a photo.
Fix: WebP/AVIF with a JPEG fallback (Part 04).
3. Lazy-loaded above the fold — deferring the fetch of the very element
LCP is measuring. Fix: loading="eager" + fetchpriority="high" (Part 05).
4. Discovered late — the image is only referenced inside a CSS
background-image rule in an external stylesheet, so the browser cannot
even start the request until the CSS has downloaded and parsed. Fix:
use a real <img> (or <picture>) in the HTML for LCP candidates, not
a CSS background — the browser's preload scanner can discover an
<img>'s src while still parsing HTML, well before CSS is involved.Google's published LCP thresholds: under 2.5 seconds is "good," 2.5–4.0 seconds is "needs improvement," and above 4.0 seconds is "poor" — measured from navigation start, on real-user data collected across actual visits, not just a single synthetic lab test. Lighthouse gives you a lab-based estimate immediately; PageSpeed Insights and the Chrome UX Report show the real-user field data your actual visitors experienced.
A Portland Furniture Retailer's LCP Goes From 6.1s to 1.8s
An online furniture retailer in Portland notices their product listing pages have quietly slipped out of Google's "good" Core Web Vitals bucket, coinciding with a real, measurable drop in organic search traffic to those exact pages. The design team had recently shipped a redesigned category hero — a large, moody lifestyle photograph behind the category title — and nobody connected the two events until an engineer ran Lighthouse directly against a product listing page.
What the audit finds
The report flags an LCP of 6.1 seconds, with the hero photograph identified as the LCP element. Digging into the Network panel, three separate, independently diagnosable problems stack on top of each other:
<div class="category-hero" style="background-image: url('/hero/category-bg-original.jpg')">
<h1>Living Room Furniture</h1>
</div>First, the image is a single 4200×1800 JPEG straight out of the photographer's export, weighing 3.8MB, serving a hero slot that never renders wider than 1600px on any real device — the exact single-file problem from Part 01. Second, it is set as a CSS background-image, invisible to the HTML preload scanner, adding roughly 400ms of pure discovery delay before the request even starts, exactly the mistake called out in Part 07. Third — and this one had been true even before the redesign — the surrounding product grid images have no width/height attributes at all, so every page load also carries a poor CLS score as the grid visibly jumps once each thumbnail's real dimensions resolve.
The fix
<picture class="category-hero">
<source
srcset="/hero/category-bg-800.avif 800w, /hero/category-bg-1600.avif 1600w"
sizes="100vw"
type="image/avif"
>
<source
srcset="/hero/category-bg-800.webp 800w, /hero/category-bg-1600.webp 1600w"
sizes="100vw"
type="image/webp"
>
<img
src="/hero/category-bg-1600.jpg"
alt="Modern living room with a grey sectional sofa and warm ambient lighting"
width="1600"
height="500"
loading="eager"
fetchpriority="high"
>
</picture>
<h1>Living Room Furniture</h1>Alongside the hero fix, the engineer runs a bulk pass adding correct width/height to every product grid thumbnail, and adds loading="lazy" to everything below the third row, which was previously loading eagerly for no reason. LCP drops from 6.1s to 1.8s — well inside Google's "good" threshold — and CLS drops from 0.34 ("poor") to 0.02 ("good"). Organic traffic to the affected pages recovers over the following search index cycle. Nothing about the visual design changed at all; every fix here was purely about how the same images were delivered.
Four Misconceptions About Responsive Images
5 Interview Questions — With Complete Answers
Responsive Image Mistakes Made Constantly, Even by Experienced Engineers
Warnings and Rendering Bugs Responsive Images Actually Produce
🎯 Key Takeaways
- ✓srcset + sizes solve resolution switching — the same crop, different file sizes, chosen automatically by the browser before any file is requested.
- ✓picture + source solve art direction — genuinely different crops, compositions, or formats per condition, something srcset alone cannot express.
- ✓sizes is not optional when using width descriptors (w) — without it the browser assumes 100vw, which is very often wrong for real layouts.
- ✓Offer WebP/AVIF ahead of a JPEG/PNG fallback inside a picture element — the browser resolves the fallback chain natively, with no JavaScript involved.
- ✓width and height (or CSS aspect-ratio) give the browser a ratio to reserve layout space with before the file loads — this is what prevents CLS, and it works alongside fluid, responsive CSS sizing rather than against it.
- ✓The LCP element on most real pages is an image — usually a hero. Oversized files, wrong formats, unnecessary lazy-loading, and CSS-only background images all independently delay it.
- ✓Never lazy-load an above-the-fold image, especially a likely LCP candidate — use loading="eager" and fetchpriority="high" instead, and reserve loading="lazy" for content genuinely below the fold.
- ✓A CSS background-image is invisible to the browser's HTML preload scanner — a real img/picture in the HTML lets the browser discover and prioritize it far earlier.
What comes next
Module 38 turns to accessibility at the CSS level — visible focus states, WCAG color contrast ratios, respecting prefers-reduced-motion, and designing hover interactions that still work on touch devices with no true hover state.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.