completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Responsive images

Send each device the image it needs with srcset, sizes and picture. A NASA photo made in six widths, loaded on a phone, a tablet, a laptop and a desktop in Chrome, Firefox and WebKit: one big file, srcset with and without sizes, a 2x cap, a picture with a square crop, density descriptors, and sizes=auto on a lazy image.

By the end, you can
  • 01
    Work out what a screen needsMultiply the width the page shows an image by the screen's pixel ratio, and find the file the browser will pick.
  • 02
    Write srcset and sizesList widths with w descriptors and a sizes that copies your layout, or use x descriptors for an image that never changes size.
  • 03
    Use picture, a cap and sizes=autoShow a different crop on narrow screens, give 3x screens a 2x file, and let lazy images measure their own slot.

One photo, four screens

Ravi is building a new page. At the top is a photo of two ospreys on their nest. A big photo like this, at the top of a page, is called the hero. Asha will open the page on her phone, on the bus. Ravi will check it on his desktop, in a browser window nearly five times as wide as hers.

Same page, same photo. Which of them needs the bigger file?

You might guess Ravi. His screen is so much bigger. But with the markup this lesson builds, Asha's phone downloaded a file 1200 pixels wide, and Ravi's desktop one 800 pixels wide. Two things explain it: how wide the page shows the photo, and how many dots each screen packs into that width.

Here are the two screens, drawn to one scale:

The top of two screens, drawn to one scale, both showing the osprey photo. Asha's phone: a window 390 CSS pixels wide, the photo 358 CSS pixels wide. One CSS pixel is 9 device pixels, so it needs 358 × 3 = 1,074 pixels across. It downloaded photo-1200.jpg, 67.2 kB. Ravi's desktop: a window 1920 CSS pixels wide, the photo 600 CSS pixels wide. One CSS pixel is 1 device pixel, so it needs 600 × 1 = 600 pixels across. It downloaded photo-800.jpg, 34.6 kB.Asha's phoneTwo ospreys on their nest358 CSS px1 CSS px is9 device pixels358 × 3 = 1,074 pxneeded across the photophoto-1200.jpgdownloaded, 67.2 kBRavi's desktopTwo ospreys on their nest600 CSS px1 CSS px is1 device pixel600 × 1 = 600 pxneeded across the photophoto-800.jpgdownloaded, 34.6 kB
The top of each screen, drawn to one scale. The photo looks bigger on Ravi's desktop. But each CSS pixel on Asha's phone is 9 device pixels, so her phone needs more pixels across the photo. It downloaded the bigger file, shaded.

The photo is bigger on Ravi's screen. But Asha's screen packs 9 dots into the space where his has 1, so her phone needs more pixels across the photo. The next two parts explain the numbers under each screen.

In Image formats, Ravi chose the kind of file. This lesson chooses its size. Images and media, in the system design course, plans a whole site's images. Optimizing images, in the Next.js course, shows a framework doing this work for you.

How wide the page shows the photo

A web page is laid out in CSS pixels. That's the unit you write in CSS, as in width: 600px. Asha's phone gives the page a window 390 CSS pixels wide. Ravi's desktop gives it 1920.

The page doesn't stretch the photo across Ravi's whole window. On wide screens, its layout puts the photo in a column 600 px wide. On narrow screens, the photo fills the window, minus 16 px of padding on each side. So the photo is 358 px wide on Asha's phone, and 600 px wide on Ravi's desktop.

How many dots the screen packs in

A screen is a grid of tiny dots of light, called device pixels. Ravi's desktop draws each CSS pixel with one device pixel. Asha's phone is much sharper: it fits three across each CSS pixel, and three down. That number, device pixels across one CSS pixel, is the screen's device pixel ratio, or pixel ratio for short. Asha's phone is 3x, and Ravi's desktop 1x. Under the two screens in the picture above, the squares are one CSS pixel each, magnified.

MDN says the ratio tells the browser "how many of the screen's actual pixels should be used to draw a single CSS pixel". JavaScript can read it as window.devicePixelRatio.

Real-life analogyA finer cloth needs more stitches

You cross-stitch the same small bird onto two cushions, one stitch in each square of the weave. The second cushion's cloth is woven three times as fine. For the bird to fill the same space on it, you need a pattern with three times as many stitches across. Use the coarse pattern on the fine cloth, and each stitch has to cover three squares. The bird looks blocky.

In real life: The space the bird fills on the cushion
In the browser: The width the page shows the photo, in CSS pixels
In real life: How finely the cloth is woven
In the browser: The pixel ratio: device pixels across each CSS pixel
In real life: Stitches across the pattern
In the browser: Pixels across the image file
In real life: A coarse pattern stretched over fine cloth
In the browser: A small file on a 3x screen, which looks blurry

So a screen needs the photo's shown width times its pixel ratio, in pixels across. Here it is for both screens:

What each screen needsPop out in the code editor (opens in a new tab)JavaScript
// The osprey photo on two screens. Widths are in CSS pixels.const phone = { shownWidth: 358, pixelRatio: 3 };const desktop = { shownWidth: 600, pixelRatio: 1 }; console.log(`Asha's phone needs ${phone.shownWidth * phone.pixelRatio} pixels across.`);console.log(`Ravi's desktop needs ${desktop.shownWidth * desktop.pixelRatio} pixels across.`);
  • Line 1 is a comment. The widths in this example are CSS pixels.
  • Line 2 describes Asha's phone: it shows the photo 358 CSS pixels wide, at pixel ratio 3.
  • Line 3 describes Ravi's desktop: 600 CSS pixels wide, at pixel ratio 1.
  • Lines 5 and 6 multiply the two. They print Asha's phone needs 1074 pixels across. and Ravi's desktop needs 600 pixels across.

There's the answer. Asha's window is about a fifth as wide as Ravi's, but her phone needs almost twice as many pixels across the photo.

Why it matters: send too few pixels, and the browser has to stretch the file. MDN warns that a raster image, a fixed grid of pixels like a JPEG, "starts to look grainy when displayed larger than its original size". Send too many, and Asha waits for pixels her screen can't show. No single file is right for every screen.

How the course tested it

To see what browsers really do, the course built Ravi's page and loaded it many ways:

  • The photo is NASA's. NASA's guidelines say its images "generally are not subject to copyright in the United States". The course saved it as JPEG at six widths: 400, 800, 1200, 1600, 2000 and 2400 pixels.
  • The page has the hero, a small round avatar under it, and a second copy of the photo far down, in a 300 px sidebar.
  • Chrome, Firefox and WebKit, the engine inside Safari, opened it on a phone, a tablet, a laptop and a desktop. Each noted which file every image came from.
  • The hero was marked up five ways. The sections below build them one at a time.
  • Chrome also loaded each way on the phone 5 times, over a link as slow as a slow 4G network: a 150 ms round trip and 1.6 Mbps down. It noted the LCP, or Largest Contentful Paint: the moment the largest thing on the screen, here the hero, was painted.

Here is the whole test at once. Pick a device and a markup. Start with Asha's phone, then try Ravi's desktop.

Playground: four devices, five ways to mark up the hero
What it downloaded
  1. The hero's filephoto-1200.jpg
  2. Its size67.2 kB
  3. Shown at358 CSS px wide
  4. Device pixels per CSS pixel3.4
  5. The screen's pixel ratio3
  6. The avatar's fileavatar-3x.jpg, 8.6 kB
  7. LCP on the slow link, in Chrome824 ms
  8. Chrome, Firefox and WebKit agreeyes
Try it yourself
Phone, 390 wide at 3x, srcset and sizes: photo-1200.jpg

sizes tells the browser the photo is 600 px wide in windows of 1000 px or more. In smaller windows, it fills the window. The page itself shows the photo as wide as the window, minus 16 px of padding on each side. In windows of 1000 px or more, it shows it 600 px wide.

Recorded in Chrome 156, Firefox 155 and WebKit 26.6 on Apple M1 Pro. LCP is the median of 5 loads on a link with a 150 ms round trip and 1.6 Mbps down.
The main idea

A screen needs the photo's shown width times its pixel ratio, in pixels across. Offer the browser the photo in several sizes, tell it how wide the photo will be shown, and it picks the smallest file that's big enough. The list of sizes is srcset. The shown width is sizes. For a different crop on narrow screens, you wrap them in <picture>.

srcset with width and density descriptors

Ravi's first version of the page is the simplest. It has one file, big enough for the biggest screen:

One file for every screenHTML
<img class="hero"  src="/photo-2400.jpg" alt="Two ospreys on a nest" width="2400" height="1600">
  • Line 1 starts the <img>. Its class, hero, is how the page's CSS finds it.
  • Line 2 names the file in src: the 2400 px photo. Then alt describes the photo for people who can't see it. Last, width and height give the photo's shape, so the browser can keep space for it before the file arrives.

Every device downloaded that same file: 207.4 kB. Asha's phone needed 1,074 pixels across and got 2,400, more than twice as many. Ravi's desktop needed 600 and got four times as many.

Real-life analogyA shop that stocks every size

Picture a shop that sells its shirt in one size only: the biggest. Everyone can get it on, but most people swim in it, and they all pay for the extra cloth. A better shop stocks the same shirt in six sizes, each with its size on the label. Each customer takes the smallest one that fits.

In real life: One giant shirt for everyone
In HTML: One 2400 px file for every device
In real life: The same shirt in six sizes
In HTML: Six files of the photo, listed in srcset
In real life: The size on each label
In HTML: A width descriptor, like 1200w
In real life: Taking the smallest one that fits
In HTML: The browser picking the smallest file that's big enough

The rail of sizes is the srcset attribute. Here is the hero with it:

The same photo at six widthsHTML
<img class="hero"  srcset="/photo-400.jpg 400w, /photo-800.jpg 800w, /photo-1200.jpg 1200w, /photo-1600.jpg 1600w, /photo-2000.jpg 2000w, /photo-2400.jpg 2400w"  src="/photo-2400.jpg" alt="Two ospreys on a nest" width="2400" height="1600">
  • Line 2 is srcset: a list of files, separated by commas. After each file comes its width descriptor, the file's width in pixels followed by w. So /photo-1200.jpg 1200w says "this file is 1200 pixels wide". MDN: it "must match the intrinsic width of the referenced image".
  • Line 3 keeps src, for browsers that don't know srcset. Browsers that do know it skip src here. The HTML standard: "the src attribute is ignored when the srcset attribute uses w descriptors."

How the browser picks a file

The browser now has six files. To pick one, it first needs the slot: how wide the image will be shown, in CSS pixels. MDN uses that word for the space an image fills. With only srcset, the browser assumes the image fills the whole window: 390 on Asha's phone.

Then it divides each file's width by the slot. The answer is how many device pixels that file would put across each CSS pixel. The HTML standard calls it the file's pixel density, and picks with one rule. In it, the candidates are the files, sorted from the lowest density to the highest. devicePixelRatio is the screen's pixel ratio:

"Let selectedSource be the first image source in candidates whose associated pixel density is greater than or equal to devicePixelRatio, if there is one; otherwise the last image source in candidates."

In plain words: take the first file with at least as many device pixels per CSS pixel as the screen has. If no file has enough, take the biggest. Here is that rule, run for Asha's phone:

The standard's rule, for Asha's phonePop out in the code editor (opens in a new tab)JavaScript
// The six files, and Asha's phone: a window 390 CSS pixels wide, at pixel ratio 3.const fileWidths = [400, 800, 1200, 1600, 2000, 2400];const slotWidth = 390;const pixelRatio = 3; const densities = fileWidths.map((width) => width / slotWidth);console.log(`Densities in a ${slotWidth} px slot: ${densities.map((density) => density.toFixed(2)).join(", ")}`); const first = densities.findIndex((density) => density >= pixelRatio);console.log(`The first one of at least ${pixelRatio} is the ${fileWidths[first]} px file.`);
  • Lines 2 to 4 are the six widths, the slot (Asha's whole window, 390 CSS pixels) and her pixel ratio, 3.
  • Line 6 divides each width by the slot. Line 7 prints Densities in a 390 px slot: 1.03, 2.05, 3.08, 4.10, 5.13, 6.15.
  • Line 9 finds the first density of at least 3. Line 10 prints The first one of at least 3 is the 1200 px file.

That's the file all three engines picked for Asha's phone. There's a shorter way to say the same rule: multiply the slot by the pixel ratio, and take the smallest file at least that wide. For Asha, 390 × 3 = 1,170, so the 1200 file. Here is what each device took:

What srcset alone pickedPop out in the code editor (opens in a new tab)JavaScript
// The hero's file on each device, with srcset and no sizes. Chrome, Firefox and WebKit picked the same.const picks = {  "phone, 390 wide at 3x": 1200,  "tablet, 820 wide at 2x": 2000,  "laptop, 1440 wide at 2x": 2400,  "desktop, 1920 wide at 1x": 2000,}; for (const [device, width] of Object.entries(picks)) console.log(`${device}: photo-${width}.jpg`);
  • Lines 3 to 6 are recorded: the file each device's hero came from.
  • Line 9 prints one line per device. The first is phone, 390 wide at 3x: photo-1200.jpg. The last is desktop, 1920 wide at 1x: photo-2000.jpg.

Each one follows the rule. The tablet's window is 820 wide at 2x: 820 × 2 = 1,640, so it took the 2000 file. Ravi's desktop: 1920 × 1 = 1,920, so 2000 again. The laptop asked for 1440 × 2 = 2,880. No file is that wide, so it took the last one, photo-2400.jpg.

Something is off, though. The laptop and the desktop show the photo only 600 px wide, yet they took files for their whole window. The next section fixes that. First, one more kind of descriptor.

Density descriptors, for an image that never changes size

The avatar under the hero is a small round photo of one osprey's head. It's 96 CSS pixels wide on every device. Its slot never changes, so only the pixel ratio matters. For an image like this, srcset can name screens instead of widths:

The avatar, one file per pixel ratioHTML
<img class="avatar"  srcset="/avatar-1x.jpg 1x, /avatar-2x.jpg 2x, /avatar-3x.jpg 3x"  src="/avatar-1x.jpg" alt="An osprey" width="96" height="96">
  • Line 2 lists three files. After each comes a density descriptor: a number followed by x. 2x means "this file is for a screen with pixel ratio 2".
  • Line 3 sets width and height to 96. The page's CSS shows the avatar 96 px wide, too.

There's no sizes. The files already say which screen each is for, so the browser has nothing to divide. The 2x file is 192 pixels wide, two for each of the 96 CSS pixels, and the 3x file is 288. Here is what each device took:

Each device's avatarPop out in the code editor (opens in a new tab)JavaScript
// The 96 px avatar: the file each device took (all three engines agreed), and each file's bytes.const picked = { phone: "3x", tablet: "2x", laptop: "2x", desktop: "1x" };const bytes = { "1x": 1867, "2x": 4869, "3x": 8598 }; for (const [device, file] of Object.entries(picked)) console.log(`${device}: avatar-${file}.jpg, ${(bytes[file] / 1000).toFixed(1)} kB`);
  • Line 2 is recorded: the file each device took. All three engines agreed.
  • Line 3 is each file's size in bytes.
  • Line 5 prints phone: avatar-3x.jpg, 8.6 kB, tablet: avatar-2x.jpg, 4.9 kB, laptop: avatar-2x.jpg, 4.9 kB and desktop: avatar-1x.jpg, 1.9 kB.

Each device took the file for its own pixel ratio. So which kind should you use? The HTML standard says the x descriptor "is not appropriate when the rendered size of the image depends on the viewport width". The viewport is the window. So:

  • Use x for an image that's the same size on every screen, like an avatar or a logo.
  • Use w for an image whose width follows the window, like the hero.

Why it matters: each screen got all the sharpness it can show, and paid for no more.

Writing a correct sizes attribute

Why did the laptop take a 2400 px file for a photo shown 600 px wide? Because of when the browser chooses. It picks the file as soon as it reads the <img> tag, before it has the page's CSS. You met this in Load the LCP resource early: the preload scanner spots an <img> in the HTML and starts fetching it at once.

So the browser can't know the layout yet. web.dev: "viewport size is the only layout information the browser has when the image request is made." Without help, it plays safe and assumes the image is as wide as the window. MDN: "If the sizes attribute is not provided, it has a default value of 100vw (the viewport width)." A vw is one hundredth of the window's width, so 100vw is the whole window.

Real-life analogyTelling the shop your size before they fetch it

You phone the shirt shop and ask them to put a shirt aside for you. The assistant can't see you, so they ask your size. Say "medium", and a medium is waiting at the till. Say nothing, and they play safe: they put aside a shirt for someone as broad as the doorway.

In real life: The assistant, who can't see you yet
In HTML: The browser, choosing before it has the CSS
In real life: Telling them your size
In HTML: The sizes attribute
In real life: Saying nothing
In HTML: No sizes: the browser assumes 100vw
In real life: A shirt for someone as broad as the doorway
In HTML: A file for an image as wide as the window

sizes is how the page says its size. web.dev calls it shorthand for "here is the size of the rendered image in the layout." Here is the hero with it, on line 3:

The hero with srcset and sizesHTML
<img class="hero"  srcset="/photo-400.jpg 400w, /photo-800.jpg 800w, /photo-1200.jpg 1200w, /photo-1600.jpg 1600w, /photo-2000.jpg 2000w, /photo-2400.jpg 2400w"  sizes="(min-width: 1000px) 600px, 100vw"  src="/photo-2400.jpg" alt="Two ospreys on a nest" width="2400" height="1600">
  • Line 3 is sizes. It says: in a window at least 1000 px wide, the photo is shown 600 px wide. In any other window, it fills the window.
  • Lines 2 and 4 are the same as before.

How to read a sizes list

A sizes value is a list, separated by commas. Each item except the last has two parts:

  • a media condition: a test about the window, in brackets. (min-width: 1000px) means "the window is at least 1000 px wide";
  • a width: how wide the image is shown when that test passes.

The last item, 100vw, has no test. It's the width to use when no test passes. The browser reads the list from the left and stops at the first test that passes. MDN: "The browser ignores everything after the first matching condition, so be careful how you order the media conditions." Here is that reading, for the four windows:

The slot in each windowPop out in the code editor (opens in a new tab)JavaScript
// The hero's sizes, "(min-width: 1000px) 600px, 100vw", read for four window widths.const slotWidth = (windowWidth) => (windowWidth >= 1000 ? 600 : windowWidth); for (const windowWidth of [390, 820, 1440, 1920]) {  console.log(`A window ${windowWidth} px wide: the slot is ${slotWidth(windowWidth)} px.`);}
  • Line 2 is the hero's sizes, quoted on line 1, written as a function. In a window at least 1000 px wide, the slot is 600. Otherwise, it's the window's own width.
  • Lines 4 to 6 run it for the four windows. The first line printed is A window 390 px wide: the slot is 390 px. The last is A window 1920 px wide: the slot is 600 px.

Where does 600 come from? From the page's CSS. sizes repeats, in the HTML, what the CSS will do once it arrives:

The layout that sizes repeatsCSS
.hero, picture img { display: block; width: 100%; height: auto; }
@media (min-width: 1000px) { .hero, picture { width: 600px; } }
  • Line 1 makes the hero as wide as the page's content: the window, minus the page's 16 px of padding on each side.
  • Line 2 makes it 600 px wide in windows at least 1000 px wide.

Then the browser multiplies the slot by the pixel ratio, as before. The laptop now asks for 600 × 2 = 1,200 pixels and takes photo-1200.jpg. Ravi's desktop asks for 600 × 1 = 600 and takes photo-800.jpg. Here is what that saved:

The big screens, without sizes and with itPop out in the code editor (opens in a new tab)JavaScript
// The hero on the two big screens, where the layout shows it 600 px wide: its bytes without sizes, and with.const bytes = {  laptop: { noSizes: 207388, sizes: 67243 },  desktop: { noSizes: 154427, sizes: 34637 },}; for (const [device, { noSizes, sizes }] of Object.entries(bytes)) console.log(`${device}: ${(noSizes / 1000).toFixed(1)} kB → ${(sizes / 1000).toFixed(1)} kB`);
  • Lines 3 and 4 are recorded: the hero's bytes on the laptop and the desktop, without sizes and with it.
  • Line 7 prints laptop: 207.4 kB → 67.2 kB and desktop: 154.4 kB → 34.6 kB.

Ravi's desktop now downloads less than a quarter of the bytes it did. The phone and the tablet didn't change. Their windows are under 1000 px, so sizes says 100vw, which is what the browser assumed anyway. Here are all three versions of the page side by side:

The hero's file on each device. Phone, 390 wide at 3x: One 2400 px file photo-2400.jpg, 207.4 kB; srcset, no sizes photo-1200.jpg, 67.2 kB; srcset and sizes photo-1200.jpg, 67.2 kB. Tablet, 820 wide at 2x: One 2400 px file photo-2400.jpg, 207.4 kB; srcset, no sizes photo-2000.jpg, 154.4 kB; srcset and sizes photo-2000.jpg, 154.4 kB. Laptop, 1440 wide at 2x: One 2400 px file photo-2400.jpg, 207.4 kB; srcset, no sizes photo-2400.jpg, 207.4 kB; srcset and sizes photo-1200.jpg, 67.2 kB. Desktop, 1920 wide at 1x: One 2400 px file photo-2400.jpg, 207.4 kB; srcset, no sizes photo-2000.jpg, 154.4 kB; srcset and sizes photo-800.jpg, 34.6 kB.Phone, 390 wide at 3xOne 2400 px filephoto-2400.jpg, 207.4 kBsrcset, no sizesphoto-1200.jpg, 67.2 kBsrcset and sizesphoto-1200.jpg, 67.2 kBTablet, 820 wide at 2xOne 2400 px filephoto-2400.jpg, 207.4 kBsrcset, no sizesphoto-2000.jpg, 154.4 kBsrcset and sizesphoto-2000.jpg, 154.4 kBLaptop, 1440 wide at 2xOne 2400 px filephoto-2400.jpg, 207.4 kBsrcset, no sizesphoto-2400.jpg, 207.4 kBsrcset and sizesphoto-1200.jpg, 67.2 kBDesktop, 1920 wide at 1xOne 2400 px filephoto-2400.jpg, 207.4 kBsrcset, no sizesphoto-2000.jpg, 154.4 kBsrcset and sizesphoto-800.jpg, 34.6 kB
Each bar is the size of the file one device downloaded, all to one scale. The shaded bars are srcset with sizes. Chrome, Firefox and WebKit picked the same files.

Close enough is fine

sizes doesn't have to be exact. On Asha's phone, it said the photo fills the window: 390 px. The CSS really showed it 358 px wide, inside the padding. So the browser asked for 390 × 3 = 1,170 pixels instead of 1,074, and still took photo-1200.jpg. The HTML standard says these sizes "do not necessarily have to match up exactly with the actual image width as specified in the CSS."

For a closer match, a width in sizes can use calc(), like calc(100vw - 32px) for the window minus 32 px of padding.

Why it matters: a sizes that's a little too wide can only make the browser take a bigger file than it needs. One that's too narrow can make it take a file with too few pixels, and the photo looks soft. If you can't be exact, be a little too wide.

picture for formats and art direction

On Asha's phone, the wide photo shrinks to a strip 358 px wide and 239 px tall. The two birds shrink with it. A square crop around the birds, shown the full width of her screen, would show them bigger.

Showing a different crop on different screens is called art direction. MDN: "the art direction problem involves wanting to change the image displayed to suit different image display sizes." srcset can't do it. web.dev: "srcset isn't a method for swapping image sources at specific breakpoints, and it isn't meant to swap one image for another."

Real-life analogyCropping a family photo for a small frame

You have a wide photo of the whole family on a beach. For the hallway, you print it big, with the whole scene. For a small frame on your desk, you don't just shrink it, or everyone would be a speck. You crop it to a close-up of the faces, and print that.

In real life: The whole beach scene, printed big
In HTML: The wide photo, on big screens
In real life: The close-up of the faces
In HTML: The square crop of the two birds
In real life: Choosing the print by the size of the frame
In HTML: A <source> chosen by media, a test about the window

In Image formats, Ravi used <picture> to offer newer formats. Each <source> inside it was chosen by its type. A <source> can also be chosen by media: the same kind of window test as in sizes. Here is the hero with a square crop for narrow windows:

A square crop for narrow windowsHTML
<picture>  <source media="(max-width: 699px)" srcset="/square-400.jpg 400w, /square-800.jpg 800w, /square-1200.jpg 1200w" sizes="100vw" width="1200" height="1200">  <img class="hero" srcset="/photo-400.jpg 400w, /photo-800.jpg 800w, /photo-1200.jpg 1200w, /photo-1600.jpg 1600w, /photo-2000.jpg 2000w, /photo-2400.jpg 2400w" sizes="(min-width: 1000px) 600px, 100vw" src="/photo-2400.jpg" alt="Two ospreys on a nest" width="2400" height="1600"></picture>
  • Lines 1 and 4 open and close the <picture>.
  • Line 2 is a <source> for windows up to 699 px wide. It offers the square crop at three widths, shown the full window wide. Its width and height give the square's shape.
  • Line 3 is the <img>, with the wide photo's srcset and sizes from before. Wider windows use it, and so do browsers that don't know <picture>.

The browser reads the sources in order and takes the first whose media test passes. The HTML standard: "If child has a media attribute, and its value does not match the environment, continue to the next child." If no source passes, it uses the <img>'s own files. Either way, the <img> is what sits on the page. A source only changes which file it shows.

Inside the chosen source, srcset and sizes work exactly as before. On Asha's phone, all three engines took square-1200.jpg: 390 × 3 = 1,170, and 1200 is the first width that's enough. The other windows are at least 700 px wide, so they took the same files as plain srcset and sizes. Here are the two crops:

Two ospreys on a nest, the whole photoWindows 700 px and wider: the photo
Two ospreys on a nest, cropped square around the birdsWindows up to 699 px: the square crop
The wide photo and the square crop. Photo: NASA/Ben Smegelsky. Each one here is itself an img with srcset and sizes="auto", so your browser picked its file.

The crop cost Asha some speed. Shown 358 by 358 instead of 358 by 239, it covers more of her screen, so it needs more pixels. It was 113.0 kB against 67.2, and the phone's LCP was 1,048 ms against 824.

Why it matters: art direction is for what people see, not for saving bytes. Use it when a shrunk photo loses its subject.

Capping device pixel ratio

Asha's phone asks for three device pixels across every CSS pixel: nine in each square, where a 2x screen asks for four. The bytes follow the pixels. photo-1200.jpg was 67.2 kB, and photo-800.jpg 34.6 kB, about half.

Can Asha see the difference between those two files on her phone? The course can measure the bytes. Whether the extra sharpness shows is something to check with your own eyes, on your own photos.

You can choose to cap the pixel ratio: give 3x screens the file a 2x screen would get. There's no attribute for that. The trick is a sizes that tells 3x screens the slot is smaller than it is. It's like telling the shop you're a size smaller than you are: the shirt is a little snug, and it costs less.

A smaller slot for 3x screensHTML
sizes="(min-resolution: 3dppx) and (max-width: 999px) 67vw, (min-width: 1000px) 600px, 100vw"
  • The first item has two tests joined by and. (min-resolution: 3dppx) passes on screens with at least 3 device pixels per CSS pixel. MDN defines dppx as "the number of dots per px unit".
  • (max-width: 999px) passes in windows narrower than 1000 px. When both pass, the slot is 67vw: 67 hundredths of the window, about two thirds.
  • The other two items are the hero's sizes from before.
What the cap makes the phone ask forPop out in the code editor (opens in a new tab)JavaScript
// Asha's phone with the capped sizes: the slot is 67vw of a 390 px window, at pixel ratio 3.const slotWidth = 390 * 0.67;const needed = slotWidth * 3; console.log(`The slot is ${slotWidth.toFixed(1)} px, so the phone asks for ${Math.round(needed)} pixels.`);console.log(`A 2x phone of the same width would ask for ${390 * 2}.`);
  • Line 2 works out 67vw of Asha's 390 px window.
  • Line 3 multiplies it by her pixel ratio, 3.
  • Line 5 prints The slot is 261.3 px, so the phone asks for 784 pixels.
  • Line 6 prints A 2x phone of the same width would ask for 780.

Both are under 800. So Asha's phone now takes photo-800.jpg, the same file a 2x phone would. Here is what each markup did on the slow link:

Asha's phone, three waysPop out in the code editor (opens in a new tab)JavaScript
// Asha's phone on the slow link, in Chrome: the hero's file, its bytes, and the median LCP of 5 loads, in ms.const phone = {  "one file": ["photo-2400.jpg", 207388, 1524],  "srcset and sizes": ["photo-1200.jpg", 67243, 824],  "capped at 2x": ["photo-800.jpg", 34637, 652],}; for (const [markup, [file, bytes, lcp]] of Object.entries(phone)) console.log(`${markup}: ${file}, ${(bytes / 1000).toFixed(1)} kB, LCP ${lcp} ms`);
  • Lines 3 to 5 are recorded: the hero's file, its bytes, and the median LCP of 5 loads. The median is the middle value once the loads are sorted.
  • Line 8 prints one file: photo-2400.jpg, 207.4 kB, LCP 1524 ms, srcset and sizes: photo-1200.jpg, 67.2 kB, LCP 824 ms and capped at 2x: photo-800.jpg, 34.6 kB, LCP 652 ms.

Every step down saved time. Going from one file to srcset and sizes brought the photo from 1,524 ms to 824. The cap brought it to 652.

The phone's LCP on the slow link, the median of 5 loads: Capped at 2x 652 ms, photo-800.jpg, 34.6 kB; srcset and sizes 824 ms, photo-1200.jpg, 67.2 kB; srcset, no sizes 828 ms, photo-1200.jpg, 67.2 kB; <picture>, square crop 1,048 ms, square-1200.jpg, 113.0 kB; One 2400 px file 1,524 ms, photo-2400.jpg, 207.4 kB.Capped at 2x652 ms, 34.6 kBsrcset and sizes824 ms, 67.2 kBsrcset, no sizes828 ms, 67.2 kB<picture>, square crop1,048 ms, 113.0 kBOne 2400 px file1,524 ms, 207.4 kB
Asha's phone, 390 wide at 3x, in Chrome. Each bar is one markup's median LCP: the shorter, the sooner Asha saw the photo. The shaded bar is the cap at 2x.

The cap has a price. sizes no longer tells the truth about the layout, and the browser believes it. It took the 800 px file to be 261 CSS pixels wide, because that's what sizes said. Only the page's CSS stretched it to 358. MDN says that without CSS, the browser shows an image at the width sizes gives, "regardless of the physical pixel dimensions of the downloaded file."

Why it matters: only cap an image whose width the CSS sets. And look at your photos on a 3x screen before you decide.

sizes=auto with lazy loading

sizes has one weak spot: it's a copy of the layout. When Ravi changes his CSS, he has to remember to change every sizes too. For some images, the browser can measure the slot itself.

Those images must be lazy-loaded. loading="lazy" tells the browser to wait until the image is close to the window. MDN: "The auto keyword indicates that the browser should use the expected layout width of the element to select the image to display." And: "This is only valid when combined with loading="lazy", as the page is expected to already have CSS and other layout information by the time the image loads."

Back to the shirt shop: this time you walk in. The assistant can see you, so you don't need to say your size. Here is the sidebar photo, far down Ravi's page:

The sidebar photoHTML
<img class="side"  loading="lazy"  srcset="/side-400.jpg 400w, /side-800.jpg 800w, /side-1200.jpg 1200w, /side-1600.jpg 1600w, /side-2000.jpg 2000w, /side-2400.jpg 2400w"  sizes="auto"  src="/side-2400.jpg" alt="Two ospreys on a nest" width="2400" height="1600">
  • Line 2 makes it lazy.
  • Line 3 offers the same six widths, under names of their own. The reason comes below.
  • Line 4 is sizes="auto": measure the slot. The page's CSS shows this photo 300 px wide on every device.
auto against 100vwPop out in the code editor (opens in a new tab)JavaScript
// A lazy photo far down the page, shown 300 px wide, scrolled into view: the file each device took, the same in all three engines.const sidebar = {  phone: { auto: 1200, vw: 1200 },  tablet: { auto: 800, vw: 2000 },  laptop: { auto: 800, vw: 2400 },  desktop: { auto: 400, vw: 2000 },}; for (const [device, { auto, vw }] of Object.entries(sidebar)) console.log(`${device}: sizes="auto" side-${auto}.jpg, sizes="100vw" side-${vw}.jpg`);
  • Lines 3 to 6 are recorded: the file each device took once the photo was scrolled into view, with auto and with 100vw.
  • Line 9 prints one line per device. The first is phone: sizes="auto" side-1200.jpg, sizes="100vw" side-1200.jpg. The last is desktop: sizes="auto" side-400.jpg, sizes="100vw" side-2000.jpg.
The 300 px sidebar photo's file on each device. Phone, 390 wide at 3x: sizes="auto" side-1200.jpg, 67.2 kB; sizes="100vw" side-1200.jpg, 67.2 kB. Tablet, 820 wide at 2x: sizes="auto" side-800.jpg, 34.6 kB; sizes="100vw" side-2000.jpg, 154.4 kB. Laptop, 1440 wide at 2x: sizes="auto" side-800.jpg, 34.6 kB; sizes="100vw" side-2400.jpg, 207.4 kB. Desktop, 1920 wide at 1x: sizes="auto" side-400.jpg, 10.8 kB; sizes="100vw" side-2000.jpg, 154.4 kB.Phone, 390 wide at 3xsizes="auto"side-1200.jpg, 67.2 kBsizes="100vw"side-1200.jpg, 67.2 kBTablet, 820 wide at 2xsizes="auto"side-800.jpg, 34.6 kBsizes="100vw"side-2000.jpg, 154.4 kBLaptop, 1440 wide at 2xsizes="auto"side-800.jpg, 34.6 kBsizes="100vw"side-2400.jpg, 207.4 kBDesktop, 1920 wide at 1xsizes="auto"side-400.jpg, 10.8 kBsizes="100vw"side-2000.jpg, 154.4 kB
The photo is shown 300 px wide on every device. With sizes="auto" (shaded), each file fits that. With 100vw, each fits the whole window. Chrome, Firefox and WebKit picked the same files.

With auto, each device took a file for a 300 px slot at its own pixel ratio. Ravi's desktop asked for 300 × 1 = 300, so 400. The laptop asked for 300 × 2 = 600, so 800. With 100vw, each took a file for its whole window, up to 2400 px on the laptop. On Asha's phone, both came out the same: 390 × 3 = 1,170 and 300 × 3 = 900 both lead to the 1200 file.

Browsers that don't know auto need a fallback. MDN: "For better backward compatibility with browsers that do not support auto, you can include fallback sizes after auto in the sizes attribute." For the sidebar, that's sizes="auto, 300px". The two crops in the picture section are built this way.

One surprise came up while recording. At first, the sidebar used the hero's file names. Chrome then showed it a bigger file it had already loaded for the hero, whatever sizes said. The standard allows that: if a sharper file's "image data is already available without a network request", then "the user agent may set selectedSource to one such image source." So the sidebar's files got names of their own.

MDN marks Safari's support as partial: it distorts SVG images that also use object-fit.

Why it matters: on a lazy image, sizes="auto" can't go stale, because it measures the layout you really have. Keep it off the hero, though. The hero should never be lazy, as Load the LCP resource early showed.

What will it download?

Each kind of image on Ravi's page needed its own markup. Read each row across: the kind of image, the markup that fits it, and what happened on Ravi's page.

Which markup fits which image
When the image…UseOn Ravi's page
Changes width with the windowsrcset with w descriptors, and a sizes that copies the layout.The laptop took a 1200 px file for its 600 px slot, not the 2400.
Is the same size on every screensrcset with x descriptors, and no sizes.Asha's phone took the 3x avatar, and Ravi's desktop the 1x.
Needs a different crop on narrow screens<picture>, with a <source> chosen by media.Asha's phone took the square crop. Wider windows took the wide photo.
Is in a newer format, with a fallback<picture>, with each <source> chosen by type.The page used JPEG only. Image formats covers this case.
Is lazy-loaded, far down the pagesizes="auto", with a normal list after it.The 300 px sidebar photo got files for a 300 px slot.

Now sort these images. For each one, does it download about what its slot needs, or more?

What will it download?
  • srcset with a sizes that matches the layout
  • A 96 px avatar with 1x, 2x and 3x files
  • A lazy image with sizes="auto"
  • A <picture> with a square crop for narrow screens
  • One 2400 px file for every screen
  • srcset without sizes, on a 2x laptop, for a 600 px slot
  • sizes="100vw" on a 300 px sidebar photo
  • srcset without sizes, on a 1x desktop, for a 600 px slot
Try it yourself
0 of 8 correct

Sort each image by whether it downloads about what its slot needs.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "srcset alone is enough." No. Without sizes, the browser assumes the image fills the window. The laptop took the 2400 px file for a 600 px slot: 207.4 kB instead of 67.2.
  • "The browser picks by the window's width." No. It picks by the slot's width times the pixel ratio. With sizes, Asha's 390 px phone took a 1200 px file, and Ravi's 1920 px desktop an 800 px one.
  • "sizes can be a percentage, like CSS." No. MDN: "you cannot use a percentage as the slot width." Use vw, px or calc().
  • "Phones need the smallest files." No. Their screens are small, but dense. Asha's 3x phone needed 1,074 pixels across the hero, and Ravi's 1x desktop only 600.
  • "sizes="auto" works on any image." No. Only on lazy ones. On other images, the browser skips auto and reads the rest of the list.

Practice exercises

Try each one before you open the hints.

Exercise 1 · Warm-upThe tablet, without sizes and with it

What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// The hero on the two big screens, and on the tablet, 820 wide: its bytes without sizes, and with.
const bytes = {
  laptop: { noSizes: 207388, sizes: 67243 },
  desktop: { noSizes: 154427, sizes: 34637 },
  tablet: { noSizes: 154427, sizes: 154427 },
};

for (const [device, { noSizes, sizes }] of Object.entries(bytes)) console.log(`${device}: ${(noSizes / 1000).toFixed(1)} kB → ${(sizes / 1000).toFixed(1)} kB`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe 3x avatar against the 1x

    What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // The 96 px avatar: the file each device took (all three engines agreed), and each file's bytes.
    const picked = { phone: "3x", tablet: "2x", laptop: "2x", desktop: "1x" };
    const bytes = { "1x": 1867, "2x": 4869, "3x": 8598 };
    
    for (const [device, file] of Object.entries(picked)) console.log(`${device}: avatar-${file}.jpg, ${(bytes[file] / 1000).toFixed(1)} kB`);
    console.log(`3x is ${(bytes["3x"] / bytes["1x"]).toFixed(1)}× the bytes of 1x, for ${3 * 3}× the pixels`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeOne big file on Asha's phone

      On the phone, with one 2400 px file, how many device pixels did the hero have for each CSS pixel?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThe capped photo on Asha's phone

        On the phone, capped at 2x, how many device pixels did the hero have for each CSS pixel?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeMixing w and x in one srcset

          Can one srcset mix width descriptors, like 800w, with density descriptors, like 2x?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWhere src fits in

            When srcset uses density descriptors, the browser counts src as one more file to choose from. Which descriptor does it give it?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, find the slot's width, multiply it by the pixel ratio, and look for the smallest file that's enough.

              Responsive images quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In this example, what does the third line print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The hero's file on each device, with srcset and no sizes. Chrome, Firefox and WebKit picked the same.
                const picks = {
                  "phone, 390 wide at 3x": 1200,
                  "tablet, 820 wide at 2x": 2000,
                  "laptop, 1440 wide at 2x": 2400,
                  "desktop, 1920 wide at 1x": 2000,
                };
                
                for (const [device, width] of Object.entries(picks)) console.log(`${device}: photo-${width}.jpg`);

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the third line of the slot example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The hero's sizes, "(min-width: 1000px) 600px, 100vw", read for four window widths.
                const slotWidth = (windowWidth) => (windowWidth >= 1000 ? 600 : windowWidth);
                
                for (const windowWidth of [390, 820, 1440, 1920]) {
                  console.log(`A window ${windowWidth} px wide: the slot is ${slotWidth(windowWidth)} px.`);
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7What does the last line of the bytes example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The hero on the two big screens, where the layout shows it 600 px wide: its bytes without sizes, and with.
                const bytes = {
                  laptop: { noSizes: 207388, sizes: 67243 },
                  desktop: { noSizes: 154427, sizes: 34637 },
                };
                
                for (const [device, { noSizes, sizes }] of Object.entries(bytes)) console.log(`${device}: ${(noSizes / 1000).toFixed(1)} kB → ${(sizes / 1000).toFixed(1)} kB`);

                Choose an answer to see the explanation.

              4. Question 4 of 7An <img> has srcset with width descriptors and no sizes. What does the browser assume about its slot?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why does a phone 390 CSS pixels wide pick a 1200 px file for a photo that fills the window?

                Choose an answer to see the explanation.

              6. Question 6 of 7When is <picture> the right tool, rather than srcset on an <img>?

                Choose an answer to see the explanation.

              7. Question 7 of 7Where does sizes="auto" work?

                Choose an answer to see the explanation.

              Key takeaways

              • A screen needs the image's shown width times its pixel ratio, in pixels across. A small 3x phone can need more than a big 1x desktop.
              • List the widths you have in srcset, with w descriptors. The browser takes the smallest file that's big enough.
              • Give sizes the width the layout shows the image. Without it, the browser assumes the whole window.
              • For an image that's the same size on every screen, use x descriptors: 1x, 2x, 3x.
              • Use <picture> with media for a different crop, and with type for newer formats.
              • Capping 3x screens at 2x about halved the phone's bytes. Look at your photos before you decide.
              • On lazy images, sizes="auto" measures the layout for you. Put a normal list after it.

              Remember the one-liner.
              Each screen needs the shown width times its pixel ratio, and not much more.

              Sources

              The facts on this page come from these docs:

              Coming next: Loading images well, also in Loading content fast: LCP. Ravi's page now sends each screen the right file. Next, you'll decide when each image loads: lazy loading, decoding, priorities, placeholders, and keeping space for every image.

              CompleteFrontend Clear concepts. Working examples.