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.
- 01Choose eager, lazy or highDecide for each image whether to fetch it now, later, or first. Say how far ahead each browser fetches lazy images.
- 02Know what decoding costsExplain why a bigger image takes longer to decode, and what
decoding="async"changes. - 03Keep the page stillKeep each image's space with
widthandheightoraspect-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:
// 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.andIt 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:
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>, likeloadingorfetchpriority
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?
widthandheight.
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.
- LCP2,412 ms
- The hero's request started488.8 ms
- The hero's priority in ChromeLow, then High
- The Priority header the server goti
- Gallery images asked for before the hero finished12
- Layout shift (CLS)0
- The page's size1,742 bytes
- Connection type Chrome saw4g
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.
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.
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.
// 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., thenFirefox fetched images up to 600 px below the window.andWebKit 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 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 pxandWebKit: 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
lazyLoadingImageMarginPx4Gto 1250: a fixed 1,250 px on a 4G connection. - Firefox sets
dom.lazy-loading.margin.bottomto 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 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.andOn 3g, Chrome fetched up to 2450 px ahead.Then it printsOn 2g, Chrome fetched up to 5950 px ahead.andOn 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.
What lazy did for Ravi's gallery
Back to Ravi's page, with all twelve gallery tiles marked loading="lazy":
// 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 msandgallery 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:
// 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 fetchedanddisplay: 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.
// 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: fetchedand0 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.
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:
// 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 msand6720 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 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 msandWebKit: 209 ms.
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.
// 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 msanddecoding="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:
// 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:
// 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 ishero: "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:
// 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 msandhero 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:
// 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 msandhero 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:
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.
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:
<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
Imagedoes.
Carrying the blur inside the page has a cost. The page itself gets bigger:
// 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 bytesandblur: 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?
// 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 msandblur: 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:
No placeholder: the photo appeared at 1,476 ms
A color placeholder: the photo appeared at 1,476 ms
A blur placeholder: the photo appeared at 1,508 msBy 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:
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
widthandheight - 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:
<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"andheight="800", the file's size in pixels. That gives the shape: 3 to 2. CSS still sets the width shown, 600 px, andheight: autolets 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:
// 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 printswidth and height: 400 px tall, the text at 416 pxandCSS 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.
- 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
Sort each image by how it should load.
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: 0orvisibility: hiddenloaded in all three engines. Onlydisplay: noneheld 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:
| When you write | What it changes | What 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 height | The 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.
This is the hero example from above, with one line added at the end. What does the last line print?
// 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`);fetchpriority saved 916 ms. The hero stopped taking turns with the tiles, so the server sent it first.
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?
// 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`);Chrome: 31× the time for 31× the pixels. Decode time grew in step with the number of pixels.
With the hero high but lazy, when did the hero's request start, in ms?
678.8 ms. The lazy hero waited for layout. Without lazy, its request started at about 489 ms.
With the hero high, what Priority header did the server get for the hero?
u=1, i: urgency 1, more urgent than any other image, and still useful in pieces. With nothing set, the hero's header was just i, so urgency 3.
Chromium skips images with very little detail for their size when it picks the LCP. From which Chrome version does it do that?
Chrome 112. The note is called "Largest Contentful Paint change in Chrome 112 to ignore low-entropy images".
The HTML standard leaves the lazy loading distance to each browser. What kind of value does it call that distance?
Implementation-defined: "The lazy load scroll margin is an implementation-defined value". Each browser picks its own, and the standard only offers suggestions, like a bigger value on slow connections.
Check your understanding
For each question, ask when the image is fetched, how urgently, and how much space the page keeps for it.
Question 1 of 7In this example, what does the second line print?
Read the code, then predict// 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.
Question 2 of 7What does the last line of this example print?
Read the code, then predict// 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.
Question 3 of 7How far ahead does Firefox fetch lazy images?
Choose an answer to see the explanation.
Question 4 of 7Why didn't lazy-loading Ravi's gallery make the hero appear sooner?
Choose an answer to see the explanation.
Question 5 of 7What does this example show about the decoding attribute?
Read the code, then predict// 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.
Question 6 of 7What does fetchpriority="high" change for a hero image in the viewport?
Choose an answer to see the explanation.
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 neverloading="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: nonearen't fetched. Underopacity: 0orvisibility: 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
widthandheight, or a CSSaspect-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:
- <img>: The Image Embed element, MDN.
- HTML Standard: Lazy loading attributes, WHATWG.
- settings.json5, the lazy loading margins, Chromium.
- StaticPrefList.yaml, dom.lazy-loading.margin, Firefox.
- LazyLoadElementObserver.cpp, WebKit.
- NetworkInformation: effectiveType property, MDN.
- CSS Display Module Level 3: display: none, W3C.
- visibility, MDN.
- HTMLImageElement: decode() method, MDN.
- PerformanceElementTiming, MDN.
- Optimize resource loading with the Fetch Priority API, web.dev.
- RFC 9218: Extensible Prioritization Scheme for HTTP, the IETF.
- Input metadata: stats(), dominant, sharp.
- Image component, Next.js.
- blur.ts, the placeholder for imported images, Next.js.
- data: URLs, MDN.
- image-blur-svg.ts, getImageBlurSvg, Next.js.
- get-img-props.ts, the placeholder style, Next.js.
- Largest Contentful Paint change in Chrome 112 to ignore low-entropy images, Chromium.
- Optimize Cumulative Layout Shift, web.dev.
Coming next: Image CDNs and pipelines, also in Loading content fast: LCP. Resize, convert, and compress images automatically, at build time or on request.