completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Loading images well

Lazy loading, decoding, priorities, placeholders, and dimensions for every image on the page, measured: how far ahead Chrome, Firefox and WebKit load lazy images, what decoding a photo costs, what fetchpriority and a lazy hero did to LCP on a slow link, what a blur placeholder adds, and the space each browser keeps for an image before it arrives.

By the end, you can
  • 01
    Choose eager, lazy or highDecide for each image whether to fetch it now, later, or first. Say how far ahead each browser fetches lazy images.
  • 02
    Know what decoding costsExplain why a bigger image takes longer to decode, and what decoding="async" changes.
  • 03
    Keep the page stillKeep each image's space with width and height or aspect-ratio, and pick a placeholder for the wait.

Every image on the page

Ravi's osprey page has grown. At the top is a title, "Wildlife at the cape". Under it sits a strip of six close-ups from the photo. Then comes the photo itself, the hero, shown 900 by 600 px. Below the hero is a gallery of twelve tiles. That's nineteen images on one page.

In Responsive images, Ravi gave each screen the right size of file. Every file here is the right size for a laptop at pixel ratio 2. Asha opens the page on her laptop, over a slow 4G connection: a 150 ms round trip and 1.6 Mbps down. The course loaded it that way in Chrome 5 times, each time as a new visitor:

The hero, with nothing setPop out in the code editor (opens in a new tab)JavaScript
// Ravi's page with nothing set, in Chrome on the slow link: the median of 5 loads, in ms from the start of the visit.const hero = { askedFor: 488.8, lastByte: 2397, painted: 2412 }; console.log(`Chrome asked for the hero at ${hero.askedFor} ms.`);console.log(`Its last byte arrived at ${hero.lastByte} ms.`);console.log(`It appeared on the screen at ${hero.painted} ms.`);
  • Line 1 is a comment. Times are in milliseconds (ms) from the start of the visit. Each is the median of the 5 loads: the middle one, once they're sorted.
  • Line 2 holds three moments in the hero's life. The first two come from Resource Timing, the browser's record of every file it fetches. The third is the page's LCP, Largest Contentful Paint: when the biggest thing on the screen was painted.
  • Lines 4 to 6 print Chrome asked for the hero at 488.8 ms., Its last byte arrived at 2397 ms. and It appeared on the screen at 2412 ms.

Look at the first two times. The hero's file is 154,427 bytes. Alone on this link, those bytes need about 0.77 seconds. Yet the file took almost two seconds to arrive, from 488.8 ms to 2,397 ms. Where did the rest of the time go?

The hero didn't travel alone. Chrome found all nineteen images in the HTML and asked for every one. They all came down Asha's one slow link together. In every load, Chrome had asked for all twelve gallery tiles before the hero's last byte left the server. Here is the page, drawn to scale:

Ravi's whole page to scale, 1280 px wide: a strip of six close-ups, the hero 900 by 600, and a gallery of twelve tiles in three rows. A dashed line marks the bottom of Asha's 800 px window. It crosses the gallery's first row. The other two rows are below it.the whole pagehero6 close-ups, in viewthe hero: what Asha came forbottom of Asha's window, 800 px down12 tiles: two rows below the window
To scale. Asha's window shows everything above the dashed line. Two of the gallery's three rows are below it.
Real-life analogyOne hatch, nineteen plates

A table of friends orders nineteen plates at a busy café. Every plate leaves the kitchen through one small hatch. The cook can't see the table, so he sends plates out a bit at a time, in order. The main course waits its turn among the side dishes, unless a note on the ticket says "main course first".

In real life: The one hatch between the kitchen and the café
In a page load: Asha's slow link, shared by every image
In real life: Nineteen plates for one table
In a page load: The page's nineteen images
In real life: The main course the table came for
In a page load: The hero, the image that becomes the LCP
In real life: A note on the order ticket
In a page load: An attribute on the <img>, like loading or fetchpriority

How LCP works called the biggest image the main course. Here the browser is the cook: it can't tell which image Asha came for. You can tell it, with a few words on each <img>. They answer five questions, one section each:

  • When to fetch it? Now, or once Asha scrolls near it: loading.
  • What does unpacking it cost? decoding.
  • How urgent is it? fetchpriority.
  • What shows until it comes? A placeholder.
  • How much space to keep? width and height.
The main idea

The browser can't see which image matters. A few words on each image tell it when to fetch the image, how urgently, how much space to keep for it, and what to show until it arrives. Get them right, and the hero comes first while nothing on the page jumps.

The course loaded Ravi's page in eight versions, each with its images marked up a little differently. Pick one to see what happened. The rest of the lesson explains every row.

Playground: one page, eight ways to load its images
What happened
  1. LCP2,412 ms
  2. The hero's request started488.8 ms
  3. The hero's priority in ChromeLow, then High
  4. The Priority header the server goti
  5. Gallery images asked for before the hero finished12
  6. Layout shift (CLS)0
  7. The page's size1,742 bytes
  8. Connection type Chrome saw4g
Try it yourself
Nothing set: LCP 2,412 ms

Every image is left as it is. Chrome asks for the hero at Low, and raises it after layout, when its request has already gone. Each value is the median of 5 loads, each as a new visitor.

Recorded in Chrome 156 on an Apple M1 Pro, in a window 1280 by 800 at pixel ratio 2. The link had a 150 ms round trip and 1.6 Mbps down, over HTTP/2. The server sent the most urgent response first.

loading=lazy and its thresholds

Look at the page map again. Two of the gallery's three rows sit below Asha's window. She may never scroll to them, so fetching them now spends her slow link on photos she might not see.

HTML lets you say "not yet". Add loading="lazy" to an <img>, and the browser holds the fetch back. MDN says it "Defers loading the image until it reaches a calculated distance from the viewport, as defined by the browser." The viewport is the part of the page you can see. Without the attribute, an image is eager: the browser fetches it straight away.

Real-life analogyA waiter who sets off just before you sit down

A good waiter doesn't carry plates to empty tables. He brings food only where people are sitting. But he doesn't wait until you've sat down, either. When he sees you a few steps from your table, he sets off, so your plate lands as you sit. On a crowded night, when the walk takes longer, he sets off sooner.

In real life: Tables nobody is sitting at
In a page load: Images far below the window
In real life: Seeing you a few steps from your table
In a page load: A lazy image coming within a set distance of the viewport
In real life: How far away you are when he sets off
In a page load: The lazy loading distance, which each browser picks
In real life: Setting off sooner on a crowded night
In a page load: A bigger distance on a slower connection

The HTML standard gives every lazy image a watcher, an IntersectionObserver: a browser object that reports when an element comes into view. It treats the image as in view once it's within a set distance of the viewport, and then the browser fetches it. The standard calls that distance the lazy load scroll margin, and leaves its size to each browser. One suggestion is the waiter's: "increase the value for slow or high-latency connections."

How far ahead each browser looks

The course built a test page with a small lazy image every 50 px below the window, down to 9,000 px, and opened it without scrolling. It did this in three engines, the part of a browser that loads and draws pages. Chrome is built on Chromium, and Safari on WebKit.

How far ahead each engine fetchedPop out in the code editor (opens in a new tab)JavaScript
// Lazy images one every 50 px below a window 800 px tall: the farthest each engine fetched without scrolling, in px.const farthest = { Chrome: 1200, Firefox: 600, WebKit: 800 }; for (const [engine, px] of Object.entries(farthest)) console.log(`${engine} fetched images up to ${px} px below the window.`);
  • Line 1 says how the test worked, in a window 1280 px wide and 800 px tall.
  • Line 2 holds the farthest image each engine fetched, in px below the window's bottom edge.
  • Line 4 prints Chrome fetched images up to 1200 px below the window., then Firefox fetched images up to 600 px below the window. and WebKit fetched images up to 800 px below the window.

Is WebKit's 800 px just the window's height? The course tried a window 600 px tall:

The same test, in a shorter windowPop out in the code editor (opens in a new tab)JavaScript
// The same test in a window 600 px tall.const farthest = { Chrome: 1200, Firefox: 600, WebKit: 600 }; for (const [engine, px] of Object.entries(farthest)) console.log(`${engine}: ${px} px`);
  • Line 2 holds the new results. Line 4 prints Chrome: 1200 px, Firefox: 600 px and WebKit: 600 px.

WebKit's distance shrank with the window. Chrome's and Firefox's didn't. Each engine's source code shows why:

  • Chromium sets lazyLoadingImageMarginPx4G to 1250: a fixed 1,250 px on a 4G connection.
  • Firefox sets dom.lazy-loading.margin.bottom to 600, and marks it as not a percentage. So it's 600 px in any window.
  • WebKit gives its watcher a margin of "100%": one window's height, whatever that is.

Chrome's farthest was 1,200, not 1,250, because the test images sat 50 px apart. The one at exactly 1,250 px wasn't fetched. Firefox and WebKit did fetch the image sitting exactly on their distance.

Slower connections look farther ahead

Chrome's 1,250 px is for 4G. Chrome gives every connection one of four speed labels, its effective type: slow-2g, 2g, 3g or 4g. MDN says it's worked out from "recently observed, round-trip time and downlink values". On the course's computer, it was 4g. Chromium has a distance for each label. To see them, the course had DevTools slow the link until Chrome reported each slower label:

Chrome, by connection typePop out in the code editor (opens in a new tab)JavaScript
// Chrome, by the connection type it reported (DevTools slowed the link for the last three): the farthest lazy image it fetched, in px.const chrome = { "4g": 1200, "3g": 2450, "2g": 5950, "slow-2g": 7950 }; for (const [type, px] of Object.entries(chrome)) console.log(`On ${type}, Chrome fetched up to ${px} px ahead.`);
  • Line 2 holds the farthest image Chrome fetched under each label.
  • Line 4 prints On 4g, Chrome fetched up to 1200 px ahead. and On 3g, Chrome fetched up to 2450 px ahead. Then it prints On 2g, Chrome fetched up to 5950 px ahead. and On slow-2g, Chrome fetched up to 7950 px ahead.

Each is again one step short of Chromium's setting: 2,500 px for 3g, 6,000 for 2g and 8,000 for slow-2g. In an 800 px window, that last one is about ten windows ahead: the waiter setting off early on a crowded night.

The farthest lazy image each engine fetched below the window without scrolling: Chrome, 4g 1,200 px; Chrome, 3g 2,450 px; Chrome, 2g 5,950 px; Chrome, slow-2g 7,950 px; Firefox 600 px; WebKit, 800 px window 800 px; WebKit, 600 px window 600 px.Chrome, 4g1,200 pxChrome, 3g2,450 pxChrome, 2g5,950 pxChrome, slow-2g7,950 pxFirefox600 pxWebKit, 800 px window800 pxWebKit, 600 px window600 px
To scale. Shaded: Chrome with DevTools slowing the link. The slower the connection, the farther ahead Chrome looked. WebKit looked one window ahead.

What lazy did for Ravi's gallery

Back to Ravi's page, with all twelve gallery tiles marked loading="lazy":

The gallery, eager and lazyPop out in the code editor (opens in a new tab)JavaScript
// Ravi's page in Chrome on the slow link: when the hero appeared (LCP), the median of 5 loads, in ms.const lcp = { "nothing set": 2412, "gallery lazy": 2416 }; for (const [setup, ms] of Object.entries(lcp)) console.log(`${setup}: ${ms} ms`);
  • Line 2 holds the page's LCP with nothing set, and with the gallery lazy. Line 4 prints nothing set: 2412 ms and gallery lazy: 2416 ms.

No real change: LCP was 2,416 ms against 2,412. The page map shows why. The gallery's lowest row starts only about 390 px below the window, far inside Chrome's 1,250 px. So right after layout, the step where the browser works out where everything sits, Chrome asked for all twelve tiles while the hero was still arriving.

Why it matters: lazy loading only holds back images farther away than the browser's distance. On a short page, everything loads anyway.

Hidden images

Some images are on the page but hidden, like the photos in a closed tab panel. The course hid lazy images inside the window in five ways. First, with display: none:

Hidden with display: nonePop out in the code editor (opens in a new tab)JavaScript
// Lazy images inside the window, hidden with display: none. Were they fetched? The same in Chrome, Firefox and WebKit.const fetched = {  "display: none on the image": false,  "display: none on its parent": false,}; for (const [how, yes] of Object.entries(fetched)) console.log(`${how}: ${yes ? "fetched" : "not fetched"}`);
  • Lines 3 and 4 are recorded. Chrome, Firefox and WebKit all agreed.
  • Line 7 prints display: none on the image: not fetched and display: none on its parent: not fetched.

The CSS standard says an element with display: none, and everything inside it, "generate no boxes or text sequences". No box, no place on the page. So the image never comes near the viewport, and the watcher never wakes it.

Hidden in other waysPop out in the code editor (opens in a new tab)JavaScript
// Lazy images inside the window, hidden in other ways. Were they fetched? The same in Chrome, Firefox and WebKit.const fetched = {  "opacity: 0": true,  "visibility: hidden": true,  "0 by 0": true,}; for (const [how, yes] of Object.entries(fetched)) console.log(`${how}: ${yes ? "fetched" : "not fetched"}`);
  • Lines 3 to 5 are the other three ways, and again the engines agreed. Line 8 prints opacity: 0: fetched, visibility: hidden: fetched and 0 by 0: fetched.

These images are out of sight, but each still has a box. MDN says a visibility: hidden box "is invisible (not drawn), but still affects layout as normal." An opacity: 0 image is drawn see-through, and a 0 by 0 one still sits at a point on the page. So each one comes into view, and loads.

Why it matters: a lazy image in a closed panel with display: none costs nothing while the panel stays closed. One faded out with opacity: 0 downloads like any other.

decoding and image decode cost

Say the hero's last byte has just arrived. Can the browser paint it now? Not yet. Image formats showed how a JPEG squeezes a photo into few bytes. Before the browser can paint it, it has to decode it: unpack the file into the colour of every single pixel. The more pixels, the more there is to unpack.

Real-life analogyA vacuum-packed duvet

A new duvet arrives vacuum-packed, in a flat, heavy parcel. The delivery is over, but you can't sleep under it yet. You cut the plastic and wait while it puffs up to full size. A king-size duvet takes longer than a cot one. While it puffs up, you can stand and watch, or make up the rest of the room.

In real life: The flat parcel arriving at your door
In a page load: The image file finishing its download
In real life: The duvet puffing up to full size
In a page load: Decoding: unpacking the file into pixels
In real life: A king-size duvet taking longer than a cot one
In a page load: A bigger image taking longer to decode
In real life: Making up the rest of the room meanwhile
In a page load: decoding="async": the rest of the page paints first

How long does the puffing up take? A script can ask for a decode with decode(). MDN says it "returns a Promise that resolves once the image is decoded and is safe to be appended to the DOM". A Promise is an object that tells you when a job has finished. The course loaded the osprey photo at three widths into memory, then timed decode() on each:

Bigger photos take longer to decodePop out in the code editor (opens in a new tab)JavaScript
// Chrome decoding the osprey photo at three widths, the file already in memory: the median of 5, in ms.const chrome = { 1200: 4, 2400: 15, 6720: 122.2 }; for (const [width, ms] of Object.entries(chrome)) console.log(`${width} px wide: ${ms} ms`);
  • Line 1 says what was timed: from giving the image its file to decode() finishing.
  • Line 2 holds Chrome's times for the photo 1200, 2400 and 6720 px wide.
  • Line 4 prints 1200 px wide: 4 ms, 2400 px wide: 15 ms and 6720 px wide: 122.2 ms.

The second photo is twice as wide as the first, and twice as tall. So it has four times the pixels, and it took about four times as long. The third is the original, 6720 by 4480 pixels: about 30 million of them. It took more than a tenth of a second. Was that just Chrome? The course decoded the original in all three engines:

The original, in three enginesPop out in the code editor (opens in a new tab)JavaScript
// The original photo, 6720 by 4480 pixels, decoded in each engine: the median of 5, in ms.const original = { Chrome: 122.2, Firefox: 148, WebKit: 209 }; for (const [engine, ms] of Object.entries(original)) console.log(`${engine}: ${ms} ms`);
  • Line 2 holds each engine's time. Firefox's and WebKit's came in whole milliseconds. Line 4 prints Chrome: 122.2 ms, Firefox: 148 ms and WebKit: 209 ms.
decode() on the photo, the median of 5: Chrome 1200 px 4 ms, 2400 px 15 ms, 6720 px 122.2 ms; Firefox 1200 px 3 ms, 2400 px 17 ms, 6720 px 148 ms; WebKit 1200 px 6 ms, 2400 px 25 ms, 6720 px 209 ms.Chrome1200 px wide4 ms2400 px wide15 ms6720 px wide122.2 msFirefox1200 px wide3 ms2400 px wide17 ms6720 px wide148 msWebKit1200 px wide6 ms2400 px wide25 ms6720 px wide209 ms
Every bar uses the same scale. Shaded: the original photo, 6720 by 4480 pixels. In every engine, it took far longer than the two smaller copies.

Every engine needed more than 100 ms for the original. Why it matters: the page shows the hero 900 px wide, so it never needs 6720 px of photo. Sending the size the screen needs, as Responsive images did, saves decoding time as well as bytes.

Should the text wait for the photo?

Say a script puts a new photo and a line of text on the page together. Should the text wait until the photo is decoded? The decoding attribute on the <img> decides. MDN describes its three values:

  • sync: "Decode the image synchronously along with rendering the other DOM content, and present everything together".
  • async: "Decode the image asynchronously, after rendering and presenting the other DOM content."
  • auto: "No preference for the decoding mode; the browser decides what is best for the user." It's the default.

The course did just that in Chrome, once for each value. The original photo was already downloaded, so only decoding was left. Element Timing, a feature that reports when a chosen element was painted, timed the text. Of the three engines, only Chrome has it.

When the text paintedPop out in the code editor (opens in a new tab)JavaScript
// Text and the 30-megapixel photo, already loaded, inserted together in Chrome: when the text painted, the median of 5, in ms.const textPainted = { sync: 130, async: 10, auto: 138.3 }; for (const [decoding, ms] of Object.entries(textPainted)) console.log(`decoding="${decoding}": the text painted after ${ms} ms`);
  • Line 2 holds when the text painted, counted from the moment the script added it: the median of 5 tries for each value.
  • Line 4 prints decoding="sync": the text painted after 130 ms, decoding="async": the text painted after 10 ms and decoding="auto": the text painted after 138.3 ms.

With sync, the text waited about as long as the photo took to decode. With async, it didn't wait. Here, auto behaved like sync. That's the duvet again: async makes up the rest of the room while the photo puffs up.

Why it matters: decoding="async" doesn't make a photo appear sooner. It stops a big photo from holding back the text around it.

fetchpriority on images

Back to the question from the start: why did the hero take almost two seconds to arrive? Chrome gives every request a priority: a label from Lowest to Highest that says how urgent it is. How browsers prioritize requests covered every kind of file. For images, web.dev gives two rules:

  • "Images inside the viewport typically start at a Low priority. After the layout is complete, Chrome discovers that they're in the viewport and boosts their priority."
  • And "the first five larger images are set to Medium priority by Chrome".

Here is what Chrome did on Ravi's page, with nothing set:

Chrome's priorities, before and after layoutPop out in the code editor (opens in a new tab)JavaScript
// Ravi's page with nothing set, in Chrome: each image's priority when it was asked for, and after layout.const priority = {  "strip, images 1 to 5": ["Medium", "High"],  "strip, image 6": ["Low", "High"],  hero: ["Low", "High"],  "gallery, row 1": ["Low", "High"],  "gallery, rows 2 and 3": ["Low", "Low"],}; for (const [image, [first, after]] of Object.entries(priority)) console.log(`${image}: ${first}, then ${after}`);
  • Line 1 says what was recorded, in the first load.
  • Lines 3 to 7 hold each group of images: its priority when Chrome asked for it, and after layout.
  • Line 10 prints a sentence for each group. The first is strip, images 1 to 5: Medium, then High. The hero's is third: hero: Low, then High.

The first five close-ups were the "first five larger images", so they started at Medium. The hero, seventh in the HTML, started at Low like every image after it. After layout, Chrome raised the images in view to High. The tiles below the window stayed Low.

So the hero did reach High. Why didn't that help? Because Chrome also tells the server how urgent each request is, with a Priority header sent with the request. Connections in practice pictured it as five friends waiting on one teapot. Here is what the server was told:

What the server was toldPop out in the code editor (opens in a new tab)JavaScript
// The same load: the Priority header each request carried to the server.const header = { "strip, images 1 to 5": "u=2, i", "strip, image 6": "i", hero: "i", gallery: "i" }; for (const [image, value] of Object.entries(header)) console.log(`${image}: "${value}"`);
  • Line 2 holds the header each group of requests carried.
  • Line 4 prints them, starting with strip, images 1 to 5: "u=2, i". The hero's line is hero: "i", the same as every tile's.

The header comes from RFC 9218. u is the urgency, from 0 to 7: the smaller the number, the sooner the file is wanted. When u is missing, RFC 9218 says: "The default is 3." i means incremental: the file is useful in pieces, as they arrive.

So the hero went out at urgency 3, like every tile. The course's server sent the most urgent files first, and files of equal urgency took turns, 4 KB at a time. Chrome's raise to High came after the request had left, header and all, and the server only read the header on arrival.

Telling the browser which image matters

You know which image Asha came for, so say so: add fetchpriority="high" to the hero's <img>. Chrome then asks for it at High from the start, and its Priority header tells the server too. Load the LCP resource early pictured this as being waved to the front of the queue:

The hero, without and with the hintPop out in the code editor (opens in a new tab)JavaScript
// Ravi's page in Chrome on the slow link: when the hero appeared (LCP), the median of 5 loads, in ms.const lcp = { "nothing set": 2412, "hero high": 1496 }; for (const [setup, ms] of Object.entries(lcp)) console.log(`${setup}: ${ms} ms`);
  • Line 2 holds LCP without the hint and with it. Line 4 prints nothing set: 2412 ms and hero high: 1496 ms.

LCP went from 2,412 ms to 1,496: almost a second sooner, from one attribute. The link was no faster, and the server still got requests for all twelve tiles before the hero's last byte left. But the hero was now the most urgent image, so the server sent it first.

A lazy hero waits for layout

Now a mistake that's easy to make, by turning on lazy loading for every image at once: a lazy hero. The course tried one that was both high and lazy:

A hero that is high but lazyPop out in the code editor (opens in a new tab)JavaScript
// The gallery lazy and the hero high, then the hero lazy as well: LCP, the median of 5 loads, in ms.const lcp = { "gallery lazy, hero high": 1476, "hero high but lazy": 1664 }; for (const [setup, ms] of Object.entries(lcp)) console.log(`${setup}: ${ms} ms`);
  • Line 2 holds the two LCPs. Line 4 prints gallery lazy, hero high: 1476 ms and hero high but lazy: 1664 ms.

Lazy undid part of the gain: LCP was 1,664 ms against 1,476. A lazy image waits until layout has worked out where it sits, so the hero's request started about 190 ms later. Load the LCP resource early called this a "round the back" sign on the urgent parcel. Here is every version of the page:

The page's LCP on the slow link in Chrome, the median of 5 loads: Nothing set 2,412 ms; Gallery lazy 2,416 ms; Hero high 1,496 ms; Gallery lazy, hero high 1,476 ms; Hero high but lazy 1,664 ms; No width or height 1,476 ms; A color placeholder 1,472 ms; A blur placeholder 1,488 ms.Nothing set2,412 msGallery lazy2,416 msHero high1,496 msGallery lazy, hero high1,476 msHero high but lazy1,664 msNo width or height1,476 msA color placeholder1,472 msA blur placeholder1,488 ms
To scale, from 0. Shaded: the gallery lazy and the hero high. Each of the four rows below it changes one more thing on that page.

Why it matters: give the LCP image fetchpriority="high", and never loading="lazy". Keep the hint for that one image. If every image asked to go first, none of them would.

Placeholders: blur-up and dominant color

With the gallery lazy and the hero high, the hero appeared at 1,476 ms. Until then, its box on the page sits empty, showing the white page behind it. A placeholder fills that box with something while the real photo is on its way.

Real-life analogyA sketch in the frame before the painting

A small gallery is waiting for a painting on loan. The frame is already on the wall. Rather than leave it empty, the staff paint the board inside it the painting's main colour, or pin up a soft pencil sketch of it. Visitors can see something is coming. The painting arrives no sooner, but the wall doesn't look broken in the meantime.

In real life: The empty frame on the wall
In a page load: The hero's box, before its file arrives
In real life: The board in the frame, painted the painting's main colour
In a page load: A colour placeholder
In real life: A soft pencil sketch of the painting
In a page load: A blur placeholder: a tiny copy of the photo, blurred
In real life: The painting arriving when it was always going to
In a page load: The hero painting at about the same time, so LCP barely moves

The simplest placeholder is one colour. The course used the hero's main colour, which the image library sharp picked out: its "most dominant sRGB colour". It goes on the image as a background colour:

The hero, with a colour placeholderHTML
<img class="hero" src="/hero.jpg" alt="Two ospreys on a nest" width="900" height="600" fetchpriority="high" style="background-color: rgb(40, 136, 200)">

Line 1 is the hero's usual tag with a style added: a background of rgb(40, 136, 200), a blue. The background is painted behind the image, so the photo covers it as it paints.

A blur-up placeholder goes further: a tiny copy of the photo, stretched to fill the box and blurred. Asha sees the photo's shapes and colours early. Next.js builds this into its Image component. Its docs say: "The image is automatically enlarged and blurred, so a very small image (10px or less) is recommended." The course made one with Next.js's own code:

  • It shrank the hero to 8 by 5 pixels, a JPEG at quality 70: the size and quality Next.js uses for an image you import.
  • It wrote that file into the page as a data URL: text that holds a whole file, so it needs no request. MDN says data URLs "allow content creators to embed small files inline in documents". This one was 503 characters long.
  • It wrapped that in an SVG drawing with a blur filter, using Next.js's own getImageBlurSvg.
  • It set the result as the hero's background image, as Next.js's Image does.

Carrying the blur inside the page has a cost. The page itself gets bigger:

What each placeholder adds to the pagePop out in the code editor (opens in a new tab)JavaScript
// Ravi's page with the gallery lazy and the hero high, and each placeholder on the hero: the page's size, in bytes.const pageBytes = { "no placeholder": 1943, color: 1987, blur: 3174 }; for (const [placeholder, bytes] of Object.entries(pageBytes)) console.log(`${placeholder}: ${bytes} bytes`);
  • Line 2 holds the page's size with each placeholder. Line 4 prints no placeholder: 1943 bytes, color: 1987 bytes and blur: 3174 bytes.

The colour added 44 bytes. The blur added 1,231, which arrive with the HTML, before any image. That's still little next to the hero's 154,427. Did either placeholder change when the hero appeared?

When the hero appeared, with each placeholderPop out in the code editor (opens in a new tab)JavaScript
// The same three pages: when the hero appeared (LCP), the median of 5 loads, in ms.const lcp = { "no placeholder": 1476, color: 1472, blur: 1488 }; for (const [placeholder, ms] of Object.entries(lcp)) console.log(`${placeholder}: LCP ${ms} ms`);
  • Line 2 holds the median LCP of 5 loads with each. Line 4 prints no placeholder: LCP 1476 ms, color: LCP 1472 ms and blur: LCP 1488 ms.

All three are within 16 ms of each other. In every load, the LCP was the hero itself, never the placeholder. A colour isn't an image, so it can't be the LCP. A blur is, but Chromium skips images with very little detail for their size. Its notes say: "The threshold is currently 0.05 bits of image data per displayed pixel, and images which fall below this threshold will not be considered for LCP." How LCP works compared such an image to a blank canvas, not the picture on the wall.

So what does a placeholder change? What Asha sees while she waits. Here is the hero's box partway through one more load of each page:

The hero's box at 1100 ms: no placeholder, the photo's top already paintedNo placeholder: the photo appeared at 1,476 ms
The hero's box at 1100 ms: a color placeholder, the photo's top already paintedA color placeholder: the photo appeared at 1,476 ms
The hero's box at 1100 ms: a blur placeholder, the photo's top already paintedA blur placeholder: the photo appeared at 1,508 ms
The hero's box 1,100 ms into one more load of each page, in Chrome. A band across the top of the photo had painted. Below it, the box still showed the white page, the colour, or the blur.

By 712 ms, Chrome had painted a band across the top of the photo, from the first part of the file. Then the screen stayed as it was until the whole file was in. In the load without a placeholder, nothing changed between 712 ms and 1,479 ms. For those three quarters of a second, Asha saw a white box, a blue box, or a blurred photo.

Why it matters: a placeholder doesn't make the photo come sooner. It gives Asha something to look at instead of an empty box.

width, height, and aspect-ratio

The hero's box needs one more thing before its file arrives: a size. Without one, the browser can't know how tall the box should be, so it makes it 0 px tall. When the photo arrives, the box opens up, and everything below it jumps down. That jump is a layout shift.

In What is web performance?, a steady page was a café table nobody bumps. Here is how a café keeps a table steady when guests are on their way:

Real-life analogyA reserved sign on the table

A party of four has booked for eight o'clock. At seven, the café puts a "Reserved" sign on a table for four. When the party walks in, they sit straight down. Nobody else's chair gets moved, and nobody's cup gets bumped. Without the sign, the staff would be pushing tables apart around other diners.

In real life: The table kept for four
In a page load: The box kept for the image, from width and height
In real life: Keeping it before anyone arrives
In a page load: Knowing the box's size before the file arrives
In real life: Moving chairs and tables when the party walks in
In a page load: A layout shift when the photo arrives
In real life: The guests sitting straight down
In a page load: The photo painting into the box kept for it

Two attributes are the reserved sign. MDN: "Including height and width enables the aspect ratio of the image to be calculated by the browser prior to the image being loaded." An aspect ratio is the shape of a box: its width divided by its height. MDN goes on: "This aspect ratio is used to reserve the space needed to display the image". You can also give the shape in CSS, with the aspect-ratio property. The course tried three pages, each with one image and a line of text below it:

Three ways to mark up the same imageHTML
<img src="/slow.jpg" alt="" style="display: block; width: 600px">
<img src="/slow.jpg" alt="" width="1200" height="800" style="display: block; width: 600px; height: auto">
<img src="/slow.jpg" alt="" style="display: block; width: 600px; aspect-ratio: 3 / 2">
  • Line 1 sets only a CSS width of 600 px. The browser knows the width, but not the height.
  • Line 2 adds width="1200" and height="800", the file's size in pixels. That gives the shape: 3 to 2. CSS still sets the width shown, 600 px, and height: auto lets the shape decide the height.
  • Line 3 leaves the attributes out and gives the same shape in CSS: aspect-ratio: 3 / 2.

The server held the image's file back for 1.5 seconds. Before it arrived, the course measured each page in all three engines:

The space kept before the file arrivedPop out in the code editor (opens in a new tab)JavaScript
// Before the file arrived, the same in Chrome, Firefox and WebKit: the image's height, and the top of the text below it, in px.const before = {  neither: [0, 16],  "width and height": [400, 416],  "CSS aspect-ratio": [400, 416],}; for (const [markup, [height, text]] of Object.entries(before)) console.log(`${markup}: ${height} px tall, the text at ${text} px`);
  • Lines 3 to 5 hold the image's height, and how far down the page the text started, in px. Chrome, Firefox and WebKit measured the same.
  • Line 8 prints neither: 0 px tall, the text at 16 px. Then it prints width and height: 400 px tall, the text at 416 px and CSS aspect-ratio: 400 px tall, the text at 416 px.

With either, the browser kept a box 400 px tall from the start: 600 px wide, at 3 to 2. Without, the image had no height, and the text sat near the top. Once the file came, every image was 400 px tall, with the text at 416 px. So only the first page jumped, by 400 px.

Then the course took width and height off Ravi's hero, on the slow link. The page's CLS, Cumulative Layout Shift, scores how much the page jumped. It was 0.3575 in every load, against 0 with the attributes. Core Web Vitals calls 0.1 or less good. One missing pair of attributes put the page at more than three times that.

Why it matters, in web.dev's words: "Always include width and height size attributes on your images and video elements. Alternatively, reserve the required space with CSS aspect-ratio or similar." The attributes only give the shape. CSS still decides the size the image is shown at.

Eager, lazy, or high?

Here are the images on Ravi's page, and a few from other pages. For each one, ask two questions. Is it in view when the page opens? And is it the image the visitor came for? Then sort it into a pile.

Eager, lazy, or high?
  • The hero photo at the top of the page
  • The first slide of a carousel at the top
  • A small logo in the header
  • Thumbnails just above the hero
  • A gallery far below the fold
  • Avatars in the comments at the end
  • Images in a closed tab panel, display: none
Try it yourself
0 of 7 correct

Sort each image by how it should load.

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

Common mix-ups

  • "Lazy images wait until they're on screen." No. Browsers fetch them a set distance ahead. Chrome fetched them up to 1,200 px ahead on 4g, and 7,950 px on slow-2g.
  • "Lazy-load every image to be safe." No. A lazy hero waits for layout. It made LCP 1,664 ms instead of 1,476.
  • "Hidden images never load." No. Lazy images under opacity: 0 or visibility: hidden loaded in all three engines. Only display: none held them back.
  • "Async decoding makes images appear sooner." No. decoding="async" lets other content paint first. It changes what waits for the decode, not how long the decode takes.
  • "A placeholder makes LCP faster." No. LCP was still the hero: 1,472 and 1,488 ms, against 1,476 without one.
  • "A CSS width is enough." No. With only a CSS width, the image was 0 px tall until its file came.

Each attribute does one job. Read across a row to see what it changes, and what it leaves alone:

Four attributes, four jobs
When you writeWhat it changesWhat it leaves alone
loading="lazy"When the request starts: only once the image is near the viewport.How urgent the request is once it starts.
fetchpriority="high"How urgent the request is, from the very first moment.When the browser finds the image. It still has to reach the <img> in the HTML.
decoding="async"Whether other content waits for the image to be decoded.How long the decode takes.
width and heightThe space kept for the image before its file arrives.The size the image is shown at. CSS decides that.

Practice exercises

Answer each one first, then open the hints if you need them.

Exercise 1 · Warm-upWhat high saved

This is the hero example from above, with one line added at the end. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// Ravi's page in Chrome on the slow link: when the hero appeared (LCP), the median of 5 loads, in ms.
const lcp = { "nothing set": 2412, "hero high": 1496 };

for (const [setup, ms] of Object.entries(lcp)) console.log(`${setup}: ${ms} ms`);
console.log(`fetchpriority saved ${lcp["nothing set"] - lcp["hero high"]} ms`);

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

    Exercise 2 · Warm-upTime and pixels

    This is Chrome's decode example, with one line added at the end. It compares the original with the copy 1200 px wide. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Chrome decoding the osprey photo at three widths, the file already in memory: the median of 5, in ms.
    const chrome = { 1200: 4, 2400: 15, 6720: 122.2 };
    
    for (const [width, ms] of Object.entries(chrome)) console.log(`${width} px wide: ${ms} ms`);
    console.log(`Chrome: ${(chrome[6720] / chrome[1200]).toFixed(0)}× the time for ${((6720 * 4480) / (1200 * 800)).toFixed(0)}× the pixels`);

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

      Exercise 3 · PracticeThe lazy hero

      With the hero high but lazy, when did the hero's request start, in ms?

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

        Exercise 4 · PracticeThe header

        With the hero high, what Priority header did the server get for the hero?

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

          Exercise 5 · ChallengeLow-content images

          Chromium skips images with very little detail for their size when it picks the LCP. From which Chrome version does it do that?

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

            Exercise 6 · ChallengeThe standard's distance

            The HTML standard leaves the lazy loading distance to each browser. What kind of value does it call that distance?

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

              Check your understanding

              For each question, ask when the image is fetched, how urgently, and how much space the page keeps for it.

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

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Lazy images inside the window, hidden in other ways. Were they fetched? The same in Chrome, Firefox and WebKit.
                const fetched = {
                  "opacity: 0": true,
                  "visibility: hidden": true,
                  "0 by 0": true,
                };
                
                for (const [how, yes] of Object.entries(fetched)) console.log(`${how}: ${yes ? "fetched" : "not fetched"}`);

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the last line of this example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The gallery lazy and the hero high, then the hero lazy as well: LCP, the median of 5 loads, in ms.
                const lcp = { "gallery lazy, hero high": 1476, "hero high but lazy": 1664 };
                
                for (const [setup, ms] of Object.entries(lcp)) console.log(`${setup}: ${ms} ms`);

                Choose an answer to see the explanation.

              3. Question 3 of 7How far ahead does Firefox fetch lazy images?

                Choose an answer to see the explanation.

              4. Question 4 of 7Why didn't lazy-loading Ravi's gallery make the hero appear sooner?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does this example show about the decoding attribute?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Text and the 30-megapixel photo, already loaded, inserted together in Chrome: when the text painted, the median of 5, in ms.
                const textPainted = { sync: 130, async: 10, auto: 138.3 };
                
                for (const [decoding, ms] of Object.entries(textPainted)) console.log(`decoding="${decoding}": the text painted after ${ms} ms`);

                Choose an answer to see the explanation.

              6. Question 6 of 7What does fetchpriority="high" change for a hero image in the viewport?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why give an image width and height when CSS already sets its width?

                Choose an answer to see the explanation.

              Key takeaways

              • Give the LCP image fetchpriority="high", and never loading="lazy".
              • Lazy-load images far below the window. Near it, they load anyway: Chrome looks 1,250 px ahead on 4g, Firefox 600 px, and WebKit one window.
              • Lazy images under display: none aren't fetched. Under opacity: 0 or visibility: hidden, they are.
              • Decode time grows with pixels. decoding="async" lets the rest of the page paint first.
              • A placeholder fills the wait, but it doesn't shorten it.
              • Give every image width and height, or a CSS aspect-ratio, so nothing jumps when it arrives.

              Remember the one-liner.
              The hero first, the rest when it's near, and every image's space kept.

              Sources

              The facts on this page come from these docs:

              Coming next: Image CDNs and pipelines, also in Loading content fast: LCP. Resize, convert, and compress images automatically, at build time or on request.

              CompleteFrontend Clear concepts. Working examples.