completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Load the LCP resource early

Make the LCP image discoverable in the HTML, give it high priority, and never lazy-load it. Twelve versions of one shop page loaded in Chrome over HTTP/2 on a Slow 4G link: the hero in the HTML, with fetchpriority, as a CSS background, added by a script, preloaded, lazy-loaded, and from another origin, with and without Timing-Allow-Origin.

By the end, you can
  • 01
    Get the LCP image found earlyName it in the HTML, or preload it when only CSS or a script names it, and check it starts with the first file.
  • 02
    Give it the right priorityMark the LCP image alone with fetchpriority=high, and never lazy-load it.
  • 03
    Weigh another originSee what a new connection costs the LCP image, why a preconnect may not help, and how Timing-Allow-Origin moves the split.

Start it with the first file

Asha opens the Leaf and Kettle tea shop's spring sale page again. The line of words appears first. Then a big photo of mint tea fills the top of the screen. A big photo like this, at the top of a page, is often called the hero. It's the largest thing Asha sees, so the moment it's painted is the page's Largest Contentful Paint, or LCP.

The four parts of LCP split the wait for that photo. One of the four is pure waiting: resource load delay. It starts when the page's first byte arrives. It ends when the browser starts loading the photo. web.dev puts removing it first on its list of fixes, and this lesson is that fix. For the wider question of what a page should load first, see Loading strategies in the system design course.

Ravi looks after the shop's website. He can put the same photo on the page in several ways. He can write it into the HTML as an <img>. He can set it as a background in the stylesheet. He can let a script add it. Asha sees the same photo in the same place every time. So does it matter which way he picks?

Here is Asha's screen each way, once the photo has shown:

Asha's screen once the mint tea photo has shown, the same each way, from the run with the median LCP. An <img> in the HTML: the photo started loading 12 ms after the first byte, LCP 888 ms. A CSS background: the photo started loading 187 ms after the first byte, LCP 1,028 ms. Added by a script: the photo started loading 427 ms after the first byte, LCP 1,176 ms.An <img> in the HTMLThe line of words and the mint tea photoresource load delay12 msLCP 888 msA CSS backgroundThe line of words and the mint tea photoresource load delay187 msLCP 1,028 msAdded by a scriptThe line of words and the mint tea photoresource load delay427 msLCP 1,176 ms
Asha's screen once the photo has shown, for each of Ravi's three ways. All three put the same words and photo in the same place, so each screen is the same real frame of the sale page, which Chrome captured for the next lesson, Remove render delay. Shaded: how long after the first byte the browser started loading the photo. The times are from this lesson's recordings.

The three screens look the same. The waits under them don't: the later the browser started loading the photo, the later Asha saw it.

To find out, the course built one shop page. Every version of it has the same four things:

  • A stylesheet, site.css.
  • A script, app.js, of 18,570 bytes. It holds React's production build, then the shop's own code.
  • The hero, a 21,617-byte photo of mint tea.
  • Six smaller photos of other teas below it.

Only the way the page shows the hero changed, twelve ways in all. Chrome loaded each version 5 times. Each time it used a new browser with a new profile, so nothing was cached. The pages came over HTTP/2, on a link as slow as a slow 4G phone network. It had a 150 ms round trip and 1.6 Mbps down. A round trip is the time for a message to reach the server and for its answer to come back.

Here are two of the twelve versions, side by side:

The same photo, 384 ms apartPop out in the code editor (opens in a new tab)JavaScript
// Two of the twelve versions, and their LCP in ms: the median of 5 runs each.const fast = { version: 'with fetchpriority="high"', lcp: 792 };const slow = { version: "added by a script", lcp: 1176 }; console.log(`The photo ${fast.version} was painted at ${fast.lcp} ms.`);console.log(`The photo ${slow.version} was painted at ${slow.lcp} ms.`);console.log(`Same photo, same bytes, ${slow.lcp - fast.lcp} ms apart.`);
  • Line 1 is a comment. Times are in milliseconds (ms), thousandths of a second. Each LCP is the median of the runs: the middle one, once they're sorted from fastest to slowest.
  • Line 2 is a version whose <img> carries fetchpriority="high", a small hint you'll meet below. Its 792 ms tied for the fastest of the twelve.
  • Line 3 is the version where a script adds the photo. Its 1176 ms was the slowest.
  • Lines 5 and 6 print The photo with fetchpriority="high" was painted at 792 ms. and The photo added by a script was painted at 1176 ms.
  • Line 7 subtracts one from the other. It prints Same photo, same bytes, 384 ms apart.

The photo was the very same file both times, so its size can't explain the gap. What changed was when the browser started loading it, and how urgently.

The course also compared six of the versions. For each, it timed when the photo started loading. It counted from the moment the page started loading its first file. Two started right then. The others started later, two of them by more than 400 ms. And the later a photo started, the later its LCP. Much of this lesson is about moving that start earlier, and not pushing it later by mistake.

Real-life analogyThe key ingredient on the first line

You're cooking a lamb curry tonight, and a friend offers to do the shopping. They read the recipe while you chop the onions. If its first line says "500 g lamb", they set off for the butcher at once. If the lamb is first mentioned on page three, they set off much later. The butcher is just as close either way. Dinner is late because the trip started late.

In real life: The recipe
In a page load: The page's HTML
In real life: Your friend, reading ahead for things to buy
In a page load: The preload scanner, reading ahead for files to fetch
In real life: The lamb, named on the first line
In a page load: An <img> with its src in the HTML
In real life: The lamb, first mentioned on page three
In a page load: A photo named only in the stylesheet or in a script
In real life: Setting off late
In a page load: Resource load delay

The friend is the preload scanner from HTML parsing and the preload scanner. It's a second reader that skims ahead in the HTML. It looks only for the names of files. When it finds one, the browser starts fetching that file at once.

The main idea

The LCP image should start loading together with the first file the page loads. Every millisecond after that is waiting. Five habits get it there:

  1. Name it in the HTML, as an <img> with a src.
  2. Mark it fetchpriority="high", and only it.
  3. If only the stylesheet or a script names it, preload it.
  4. Never lazy-load it.
  5. Serve it from the page's own origin.

Each habit has its own section below. First, try the twelve versions yourself. Pick one to see when its hero started, the priority Chrome gave it, and its four parts.

Playground: one hero, twelve ways
The hero
  1. The hero startedat 487 ms, 0 ms after the first file
  2. Chrome's priority for itMedium → High
  3. Time to first byte481 ms
  4. Resource load delay12 ms
  5. Resource load duration382 ms
  6. Element render delay13 ms
  7. LCP888 ms
Try it yourself
An <img> in the HTML: LCP 888 ms

From the run whose LCP was the median of 5. The first row says when the hero started loading. The second shows the priority Chrome asked for it at, then the one it ended with. The rest are its four parts and their total, LCP.

Recorded in Chrome 156 on Apple M1 Pro, over HTTP/2 in an 800 by 600 window, on a link with a 150 ms round trip and 1.6 Mbps down.

Discoverable in the initial HTML

A photo is discoverable when the browser can find it just by reading the HTML. It doesn't have to load or run anything else first. web.dev gives a simple test: "your LCP resource should start loading at the same time as the first resource loaded by that page." A resource is any file the page loads. On the shop page, the first one was the stylesheet, site.css.

Why that moment? The browser can't load a file until the HTML that names it has arrived. A photo named in the HTML is found in the same read as the stylesheet. So the two can start together. A photo named anywhere else is found later.

Three places to name the photo

Ravi's first three versions name the photo in three places. The first names it right in the HTML:

The photo in the HTMLHTML
<p id="intro">Everything in the shop is 20% off until Sunday.</p><img class="hero" src="/hero.jpg" alt="Mint tea" width="640" height="427">
  • Line 1 is the line of words Asha sees first.
  • Line 2 is the hero. Its src names the file, /hero.jpg, in the HTML itself. The other attributes give its description and its size on the screen.

The second version has no file name in the HTML. Where the photo goes, there's an empty box: <div class="hero hero-bg" role="img" aria-label="Mint tea"></div>. The stylesheet fills the box with the photo:

The photo as a CSS background, in site.cssCSS
.hero { display: block; width: 640px; height: 427px; }.hero-bg { background: url(/hero.jpg) center / cover; }
  • Line 1 makes every hero box 640 by 427 pixels.
  • Line 2 is the only place the photo is named. background: url(/hero.jpg) draws it as the box's background.

The third version has an empty box too: <div id="root"></div>. The shop's script fills it. After React's build, the shop's own code starts with these two lines:

The photo added by the shop's scriptJavaScript
const root = document.getElementById("root");if (root) root.innerHTML = '<img class="hero" src="/hero.jpg" alt="Mint tea" width="640" height="427">';
  • Line 1 finds the empty box.
  • Line 2 writes an <img> into it. Only now does the page name the photo.

Now look at the three from the browser's side. The preload scanner looks for file names in the HTML only. It doesn't apply the stylesheet, and it doesn't run scripts. So in the second and third versions, it walks right past the photo.

web.dev lists both cases. One is an image "dynamically added to the page using JavaScript". The other is an element that "requires a CSS background image". Before it can find the photo, web.dev says, "the browser needs to run the script or apply the stylesheet". The course timed all three:

When each version started the photoPop out in the code editor (opens in a new tab)JavaScript
// How long after the page's first file each version started loading the photo, in ms.// From the run with the median LCP, of 5.const versions = {  "An <img> in the HTML": { after: 0.3, lcp: 888 },  "A CSS background": { after: 182.6, lcp: 1028 },  "Added by a script": { after: 422.8, lcp: 1176 },}; for (const [version, { after, lcp }] of Object.entries(versions)) {  console.log(`${version}: started ${Math.round(after)} ms after the first file. LCP ${lcp} ms.`);}
  • Lines 1 and 2 are comments. Each after is how long after the first file the photo started loading.
  • Lines 4 to 6 hold what was recorded for the three versions.
  • Line 10 prints one sentence per version. The first is An <img> in the HTML: started 0 ms after the first file. LCP 888 ms.
  • The second is A CSS background: started 183 ms after the first file. LCP 1028 ms.
  • The third is Added by a script: started 423 ms after the first file. LCP 1176 ms.

The <img> started 0.3 ms after the stylesheet: web.dev's test, passed. The CSS background waited until the stylesheet had arrived and been applied. The script's photo waited longest. All 18,570 bytes of app.js had to arrive and run first. In the four parts of LCP, that wait is resource load delay: 12 ms, 187 ms and 427 ms.

Why it matters: only the moment the photo was found changed, and LCP moved with it. So if you can, write the hero as an <img> in the HTML. If you can't, a preload can name it there for you, as you'll see further down.

fetchpriority=high

Found early isn't the same as served first. The shop page's HTML names nine files: the stylesheet, the script and seven photos. The link can't carry them all at once. So Chrome gives every request a priority, as How browsers prioritize requests showed. A priority is a label that says how urgent the request is, from Lowest to Highest.

Chrome sends that label to the server along with the request. The course's server sent the most urgent file first. Photos with the same label took turns, 4 KB at a time.

So which label does a photo get? web.dev explains that images start at Low by default. Layout is the step where the browser works out where everything goes on the screen. Only then does it know which photos are in view. "At layout time, images within the viewport are boosted" to High. The viewport is the part of the page you can see. And since Chrome 117, "the first 5 large images are set to" Medium, to speed this up.

On the shop page, Chrome asked for five photos at Medium: the hero and the first four product photos. So the hero took turns with four smaller photos, though it was the one Asha came for.

Real-life analogyWaved to the front of the queue

At a busy café, orders are made in the order people queued. You're carrying the order your whole table is waiting for. But four people with coffees are ahead of you. The manager knows your order matters, and waves you to the front. The kitchen is no faster than before. You just stop waiting behind four coffees.

In real life: The queue at the counter
In a page load: Requests waiting their turn on the link
In real life: Your order, behind four coffees
In a page load: The hero, asked for at Medium along with four product photos
In real life: The manager waving you forward
In a page load: fetchpriority="high" on the hero's <img>
In real life: The same kitchen, working as fast as ever
In a page load: The same link, at the same 1.6 Mbps

Here, you're the manager. You know which photo matters, so you can say so. Add fetchpriority="high" to the hero's <img>. Then Chrome asks for it at High from the start, without waiting for layout. The course tried it:

The photo, without and with the hintPop out in the code editor (opens in a new tab)JavaScript
// The photo in the HTML, without and with fetchpriority="high". From the run with the median LCP, in ms.const versions = {  "Without the hint": { askedAt: "Medium", loadDuration: 382.1, lcp: 888 },  "With the hint": { askedAt: "High", loadDuration: 288.3, lcp: 792 },}; for (const [version, { askedAt, loadDuration, lcp }] of Object.entries(versions)) {  console.log(`${version}: asked for at ${askedAt}, took ${Math.round(loadDuration)} ms to arrive. LCP ${lcp} ms.`);}
  • Line 1 is a comment. Both versions have the photo in the HTML. Only the hint differs.
  • Lines 3 and 4 hold the priority Chrome asked for the photo at, how long it took to arrive, and LCP. The time to arrive is the photo's resource load duration.
  • Line 8 prints Without the hint: asked for at Medium, took 382 ms to arrive. LCP 888 ms.
  • Then it prints With the hint: asked for at High, took 288 ms to arrive. LCP 792 ms.

Both photos started loading 0.3 ms after the first file. The hint didn't change when they started. It changed their place in the queue. At High, the hero went ahead of the four product photos still at Medium, and arrived 94 ms sooner. LCP fell by 96 ms.

Chrome did raise the hero to High in the first version too, at layout. But by then its request had already gone out marked Medium. The course's server only looked at the label a request carried when it arrived. So the raise came too late to help.

Waving everyone forward

If one hint helps, why not mark every photo? Because if the manager waves everyone forward, the queue is just a queue again. The course tried it, with fetchpriority="high" on all seven images:

The hint on no image, one image, and all sevenPop out in the code editor (opens in a new tab)JavaScript
// fetchpriority="high" on no image, on the photo alone, and on all seven images.// How long the photo took to arrive, and LCP. From the run with the median LCP, in ms.const versions = {  "On no image": { loadDuration: 382.1, lcp: 888 },  "On the photo alone": { loadDuration: 288.3, lcp: 792 },  "On all seven images": { loadDuration: 457.3, lcp: 956 },}; for (const [version, { loadDuration, lcp }] of Object.entries(versions)) {  console.log(`${version}: the photo took ${Math.round(loadDuration)} ms to arrive. LCP ${lcp} ms.`);}
  • Lines 1 and 2 are comments.
  • Lines 4 to 6 hold the hint on no image, on the photo alone, and on all seven.
  • Line 10 prints On no image: the photo took 382 ms to arrive. LCP 888 ms. and On the photo alone: the photo took 288 ms to arrive. LCP 792 ms.
  • Last, it prints On all seven images: the photo took 457 ms to arrive. LCP 956 ms.

With all seven at High, the hero took turns with six photos instead of four. It arrived later than with no hint at all, and LCP was 68 ms worse. web.dev warns about exactly this: "setting a high priority on more than one or two images makes priority setting unhelpful in reducing LCP." Here are the three versions as bars:

The photo in the HTML, as its four parts, from the run with the median LCP. No fetchpriority: LCP 888 ms, load duration 382 ms. fetchpriority="high" on the photo: LCP 792 ms, load duration 288 ms. fetchpriority="high" on all seven: LCP 956 ms, load duration 457 ms.No fetchpriorityLCP 888 ms, load duration 382 msfetchpriority="high" on the photoLCP 792 ms, load duration 288 msfetchpriority="high" on all sevenLCP 956 ms, load duration 457 ms
Each bar is one version's LCP in its four parts. Shaded: load duration, the photo's download. The load delay is just as thin in all three bars: the hint changed how long the download took, not when it started.

web.dev adds that "The fetchpriority attribute is a hint, not a directive". In other words, it's a request, not an order. The browser tries to follow it, but it may weigh other things too. And a server may not follow priorities at all.

Why it matters: the hint only helps when it singles one photo out. Put it on the photo you expect to be the LCP, and nowhere else.

Preloading background and script images

Sometimes the hero can't be an <img> in the HTML. A design may need it as a CSS background. Or an app may build the whole page in a script. You can still name the photo in the HTML, in another spot: a preload in the <head>.

Resource hints compared a preload to asking the kitchen to start your main course before you arrive. It tells the browser: fetch this exact file now, because this page will need it. web.dev: "Preload is still required for early discovery of LCP images included as CSS backgrounds. To boost your background images' priority, include fetchpriority='high' on the preload."

The course added one line to two versions: the CSS background one and the script one. The line goes in the head, just before the stylesheet. Here are the last three lines of the CSS version's head:

A preload before the stylesheetHTML
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high"><link rel="stylesheet" href="/site.css"><script src="/app.js" defer></script>
  • Line 1 is the preload. rel="preload" makes it one. href is the photo's exact address, and as="image" says what kind of file it is.
  • Its fetchpriority="high" matters too. web.dev explains that an LCP image, "when preloaded, will still get a low priority." The hint lifts it.
  • Lines 2 and 3 are the stylesheet and the script. Both come after the preload.
The CSS background, without and with a preloadPop out in the code editor (opens in a new tab)JavaScript
// The CSS background version, without and with the preload. From the run with the median LCP, in ms.const versions = {  "Without a preload": { ttfb: 481.3, loadDelay: 187.1, loadDuration: 349.1, renderDelay: 10.5 },  "With a preload": { ttfb: 479.8, loadDelay: 7.9, loadDuration: 290.3, renderDelay: 14 },}; for (const [version, parts] of Object.entries(versions)) {  const lcp = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay;  console.log(`${version}: load delay ${Math.round(parts.loadDelay)} ms, render delay ${Math.round(parts.renderDelay)} ms. LCP ${Math.round(lcp)} ms.`);}
  • Lines 3 and 4 hold the CSS background version's four parts, without and with the preload.
  • Line 8 adds the four parts up, to get LCP.
  • Line 9 prints Without a preload: load delay 187 ms, render delay 11 ms. LCP 1028 ms.
  • Then it prints With a preload: load delay 8 ms, render delay 14 ms. LCP 792 ms.

The load delay nearly vanished. The photo no longer waited for the stylesheet: it was the page's very first file. LCP fell from 1,028 ms to 792 ms, tied with fetchpriority="high" for the fastest of the twelve. Render delay stayed small: the photo was painted 14 ms after it arrived.

A preload can't do the script's job

Now the script version. The preload fetches the photo early. But it can't create the <img>. Only the script does that.

The script's photo, without and with a preloadPop out in the code editor (opens in a new tab)JavaScript
// The version a script adds the photo to, without and with the preload. From the run with the median LCP, in ms.const versions = {  "Without a preload": { ttfb: 478.9, loadDelay: 426.7, loadDuration: 263.5, renderDelay: 6.9 },  "With a preload": { ttfb: 479.8, loadDelay: 11.1, loadDuration: 288.7, renderDelay: 228.4 },}; for (const [version, parts] of Object.entries(versions)) {  const lcp = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay;  console.log(`${version}: load delay ${Math.round(parts.loadDelay)} ms, render delay ${Math.round(parts.renderDelay)} ms. LCP ${Math.round(lcp)} ms.`);}
  • Lines 3 and 4 hold the script version's four parts, without and with the preload.
  • Line 9 prints Without a preload: load delay 427 ms, render delay 7 ms. LCP 1176 ms.
  • Then it prints With a preload: load delay 11 ms, render delay 228 ms. LCP 1008 ms.

Load delay fell from 427 ms to 11 ms, as before. But render delay grew from 7 ms to 228 ms. The photo arrived early, and then waited. There was no <img> to paint it in until the script had run. It's like getting to the cinema early. You still wait for the film to start.

LCP still fell by 168 ms, because part of the saving stayed. Why it matters: after you add a preload, check render delay too, not just load delay. The picture below shows where the time went in all four versions.

Each page's LCP as its four parts in order, from the run with the median LCP. A CSS background: LCP 1,028 ms, delays 187 ms + 11 ms. A CSS background, preloaded: LCP 792 ms, delays 8 ms + 14 ms. Added by a script: LCP 1,176 ms, delays 427 ms + 7 ms. Added by a script, preloaded: LCP 1,008 ms, delays 11 ms + 228 ms.A CSS backgroundLCP 1,028 ms, delays 187 ms + 11 msA CSS background, preloadedLCP 792 ms, delays 8 ms + 14 msAdded by a scriptLCP 1,176 ms, delays 427 ms + 7 msAdded by a script, preloadedLCP 1,008 ms, delays 11 ms + 228 ms
Each bar is one version's LCP in its four parts: TTFB, load delay, load duration and render delay. Shaded: the two delays. The preload took away the CSS background's load delay. For the script's photo, most of that time moved into render delay.

Preload or fetchpriority?

The two are easy to mix up, because both get the photo sooner. web.dev draws the line between them. "Preload lets the browser discover a resource early", it says, while "Fetch Priority doesn't help with discoverability". Read the table across: each row is a tool, what it changes, and when the LCP image needs it.

Preload or fetchpriority?
ToolWhat it changesWhen the LCP image needs it
fetchpriority="high" on the <img>How urgent the request is. The browser still has to find the photo first.When the photo is already an <img> in the HTML.
<link rel="preload">When the browser finds the photo. It fetches it from the head, before anything else names it.When only the stylesheet or a script names the photo.
A preload with fetchpriority="high"Both: the photo is found early, and asked for at High.Whenever you preload the LCP image. A preload alone still gets it a low priority.

The lazy-loading mistake

A long page can have photos far below the first screen. Loading them all at once spends the link on photos nobody may scroll to. So a page can lazy-load an image: wait to fetch it until it's about to come into view. The fold is the bottom edge of the first screen. web.dev recommends lazy loading for the photos below it: "Lazy-load below-the-fold content so the browser can use the available bandwidth for more critical above-the-fold resources." Bandwidth is how much the link can carry each second.

The mistake is to lazy-load the photo at the top too. It's easy to do by accident. For example, someone turns on lazy loading for every image on a site at once.

Real-life analogyA "round the back" sign on the urgent parcel

A shop gets parcels all day. Most can wait, so they carry a sign: "Deliveries round the back, only when asked." The courier leaves them in the yard until someone asks for them. Now put that sign on the one parcel the shop must open first thing. It sits in the yard until someone notices it's missing and goes to get it.

In real life: Parcels for later, left round the back
In a page load: Photos further down the page, lazy-loaded
In real life: The sign on the urgent parcel
In a page load: loading="lazy" on the hero
In real life: Someone noticing the parcel is missing
In a page load: Layout finding the hero on the screen
In real life: The parcel opened late
In a page load: A late LCP

The course lazy-loaded the hero in two ways:

Two ways to lazy-load the photoHTML
<img class="hero" src="/hero.jpg" alt="Mint tea" width="640" height="427" loading="lazy">
<img class="hero" data-src="/hero.jpg" alt="Mint tea" width="640" height="427">
  • Line 1 uses loading="lazy", the browser's own lazy loading. The address is still in src, but the browser waits before using it.
  • Line 2 is for a lazy-loading script. The address is in data-src instead of src. A data- attribute holds data for the page's own scripts, so the browser loads nothing from it.

The rest of the shop's own code is that lazy-loading script:

The shop's lazy-loading scriptJavaScript
const observer = new IntersectionObserver((entries) => {  for (const entry of entries) {    if (!entry.isIntersecting) continue;    entry.target.src = entry.target.dataset.src;    observer.unobserve(entry.target);  }});for (const image of document.querySelectorAll("img[data-src]")) observer.observe(image);
  • Line 1 makes an IntersectionObserver. It's a browser object that reports when an element enters or leaves the visible part of the page.
  • Lines 2 and 3 go through each report, and skip images that aren't in view.
  • Line 4 copies the address from data-src into src. Only now does the photo start loading.
  • Line 5 stops watching that image.
  • Line 8 watches every image that has a data-src.

Both ways wait for the browser to see that the photo is on the screen. web.dev explains that a lazy image "won't be loaded until after layout confirms the image is in the viewport". So it may start later than it otherwise would. The course measured how much later:

The photo, plain and lazy-loaded twicePop out in the code editor (opens in a new tab)JavaScript
// How long after the page's first file each version started loading the photo, in ms.// From the run with the median LCP, of 5.const versions = {  "An <img> in the HTML": { after: 0.3, lcp: 888 },  'With loading="lazy"': { after: 193.6, lcp: 1032 },  "With a lazy-loading script": { after: 400.5, lcp: 1160 },}; for (const [version, { after, lcp }] of Object.entries(versions)) {  console.log(`${version}: started ${Math.round(after)} ms after the first file. LCP ${lcp} ms.`);}
  • Lines 4 to 6 hold the plain <img> for comparison, then the two lazy versions.
  • Line 10 prints An <img> in the HTML: started 0 ms after the first file. LCP 888 ms.
  • Then it prints With loading="lazy": started 194 ms after the first file. LCP 1032 ms.
  • Last, it prints With a lazy-loading script: started 401 ms after the first file. LCP 1160 ms.

loading="lazy" waited for layout. It started only 11 ms after the CSS background version did. And that one had to wait for the stylesheet. The lazy-loading script waited for all of app.js, like the version where a script adds the photo. Their LCPs were 16 ms apart.

There was a second cost. Chrome asked for the lazy photo at Low, and it stayed Low to the end. web.dev puts it plainly: "Never lazy-load your LCP image, as that will always lead to unnecessary resource load delay, and will have a negative impact on LCP."

Why it matters: lazy loading is a good tool for photos below the fold. On the hero, it does harm. The scanner could have fetched the photo at once, but now it waits for the page to be laid out.

Serving it from your own origin

One more idea: keep the photo somewhere else. An image CDN is a service that stores your images and sends them from its own servers. The course moved the hero to a second server, standing in for one, at https://img.cdn.test/hero.jpg. That's another origin. An origin is the scheme, host and port together. The shop page lives on https://shop.test, and the photo now lives on https://img.cdn.test.

Real-life analogyThe shop next door, or one across town

You're at the market buying vegetables. If the bread stall is next door, you just step over. If the only good bread is across town, you need a new trip. You go back to the car, drive, and find a space to park. The loaf costs the same. The trip is what takes the time.

In real life: The market you're already at
In a page load: The page's own origin, with a connection already open
In real life: Stepping over to the next stall
In a page load: Asking for the photo on that same connection
In real life: Driving across town and parking
In a page load: Opening a new connection: TCP, then TLS
In real life: The same loaf either way
In a page load: The same 21,617-byte photo

web.dev gives the same reason. An LCP image on another origin can start late, "as these requests require the browser to connect to that origin before the resource can start loading." Its advice: when you can, "host critical resources on the same origin as your HTML document resource". The course measured the cost. It also tried a preconnect, a hint that opens the connection to an origin early:

Own origin or anotherPop out in the code editor (opens in a new tab)JavaScript
// LCP in ms, the median of 5 runs, with the photo on the page's own origin and on another.const own = 888;const others = {  "From another origin": 1116,  "From another origin, preconnected": 1112,}; for (const [version, lcp] of Object.entries(others)) {  console.log(`${version}: LCP ${lcp} ms, ${lcp - own} ms later than from its own origin.`);}
  • Line 2 is the median LCP with the photo on the shop's own origin.
  • Lines 4 and 5 are the photo on the other origin, without and with a preconnect.
  • Line 9 prints From another origin: LCP 1116 ms, 228 ms later than from its own origin.
  • Then it prints From another origin, preconnected: LCP 1112 ms, 224 ms later than from its own origin.

The 228 ms went on the trip. Before asking for the photo, the browser had to open a connection to the new origin. That takes a TCP handshake to open the line, then a TLS handshake to make it private. Each costs a round trip or more. In this test, Chrome found each server's address instantly. On a real site, a DNS lookup would find the address first, and add more time (see DNS lookups).

What about a preconnect? Resource hints compared it to phoning the restaurant on your way, so it's ready when you walk in. Here it saved 4 ms, and the two versions' runs overlapped, so it made no real difference. In both versions, the photo started loading within 1 ms of the first file. So the photo's own request opened the connection as early as the hint could. A preconnect helps when the browser would find the origin late, like a font named inside a stylesheet.

Seeing the connection in your numbers

Measuring this has a catch. For a file from another origin, the browser hides most of its timing details. A header is a short note a server sends along with a file. MDN says that without a Timing-Allow-Origin header from that origin, "the browser reports these values as zero". One of them is requestStart, the moment the request left. The four-part split from the last lesson needs it. Without it, the split falls back to startTime, when the browser began to fetch.

It's like a courier's tracking page that shows only "sent" and "delivered", with none of the scans in between. The course sent the header from the other origin in one version:

The same photo, split two waysPop out in the code editor (opens in a new tab)JavaScript
// The photo from another origin, without and with Timing-Allow-Origin. From the run with the median LCP, in ms.const versions = {  "Without the header": { ttfb: 480.1, loadDelay: 4.6, loadDuration: 615, renderDelay: 16.3 },  "With the header": { ttfb: 482.5, loadDelay: 353.7, loadDuration: 269.5, renderDelay: 10.3 },}; for (const [version, { loadDelay, loadDuration }] of Object.entries(versions)) {  console.log(`${version}: load delay ${Math.round(loadDelay)} ms, load duration ${Math.round(loadDuration)} ms.`);}
  • Lines 3 and 4 hold the photo's four parts from the other origin, without and with the header.
  • Line 8 prints Without the header: load delay 5 ms, load duration 615 ms.
  • Then it prints With the header: load delay 354 ms, load duration 270 ms.
The hero from another origin, from the run with the median LCP, as its four parts. Without Timing-Allow-Origin: LCP 1,116 ms, load duration 615 ms. With Timing-Allow-Origin: LCP 1,116 ms, load delay 354 ms.Without Timing-Allow-OriginLCP 1,116 ms, load duration 615 msWith Timing-Allow-OriginLCP 1,116 ms, load delay 354 ms
Each bar is the photo from another origin, in its four parts. Shaded: the part the new connection was counted in. Without the header, it hides inside load duration. With it, it shows up as load delay.

Without the header, the split couldn't see when the request left. So the connection was counted as load duration. With the header, the connection showed up in load delay, where it belongs. It took 341 ms. Then the photo itself took 270 ms to arrive.

That download was quicker than the 382 ms from the shop's own server. There, the photo took turns with four product photos. So the new origin cost 341 ms for the connection, and won some of it back on the download. That's why LCP grew by 228 ms, not more.

The header didn't change the load itself. The two versions' LCPs fell in the same spread, 1,108 to 1,156 ms. It changed which part the connection was counted in. Why it matters: without the header, a slow connection looks like a slow download. You might shrink the photo when the real fix is to move it.

Early or late?

Each choice below changes when the hero starts loading, or how urgently. For each one, ask two things. Can the browser find the photo the moment the HTML arrives? And can it ask for the photo at High? Then sort the choice into its pile.

Early or late?
  • An <img> with its src in the HTML
  • fetchpriority=high on the LCP image only
  • A preload with fetchpriority=high for a CSS background hero
  • Serving the LCP image from the page's own origin
  • loading="lazy" on the LCP image
  • A lazy-loading script that copies data-src into src
  • An LCP image added by JavaScript, with no preload
  • fetchpriority=high on every image
Try it yourself
0 of 8 correct

Sort each choice by whether it gets the LCP image started early or holds it back.

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

Common mix-ups

  • "A preconnect fixes an LCP image on another origin." No. With the <img> in the HTML, the photo's own request opens the connection just as early. LCP was 1,116 ms without it and 1,112 ms with it.
  • "fetchpriority=high on every image makes them all faster." No. The photos still share one link. With all seven at High, LCP went to 956 ms, against 888 ms with no hint at all.
  • "A preload fixes an image that a script adds." No. It fixes the load delay. The photo then waits for the script to make its <img>, and render delay grew to 228 ms.
  • "A preload makes the photo urgent." No. A preload makes the browser find the photo early. Add fetchpriority="high" to it, or the photo is still asked for at a low priority.
  • "loading=lazy is harmless on a photo that's on screen." No. The browser waits for layout first. The hero started 194 ms late, at Low priority.
  • "Timing-Allow-Origin makes the photo load faster." No. It changes what the timing data shows, not the load.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upA preloaded background, started

The course added the preloaded CSS background to the discovery example. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// How long after the page's first file each version started loading the photo, in ms.
// From the run with the median LCP, of 5.
const versions = {
  "An <img> in the HTML": { after: 0.3, lcp: 888 },
  "A CSS background": { after: 182.6, lcp: 1028 },
  "Added by a script": { after: 422.8, lcp: 1176 },
  "A CSS background, preloaded": { after: 0, lcp: 792 },
};

for (const [version, { after, lcp }] of Object.entries(versions)) {
  console.log(`${version}: started ${Math.round(after)} ms after the first file. LCP ${lcp} ms.`);
}

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

    Exercise 2 · Warm-upThe lazy-loading script's LCP

    These are the four parts of the version with a lazy-loading script. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // The lazy-loading script version's four parts. From the run with the median LCP, in ms.
    const parts = { ttfb: 482.6, loadDelay: 405.6, loadDuration: 263.9, renderDelay: 7.9 };
    const lcp = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay;
    
    console.log(`Load delay: ${Math.round(parts.loadDelay)} ms.`);
    console.log(`LCP: ${Math.round(lcp)} ms.`);

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

      Exercise 3 · PracticeThe CSS background's priority

      With the hero as a CSS background, which priority did Chrome give it when it asked for it, and which did it end with?

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

        Exercise 4 · PracticePreconnected, still hidden

        The preconnected version didn't send Timing-Allow-Origin. How many milliseconds was the hero's resource load duration?

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

          Exercise 5 · ChallengeCarousel slides

          Which fetchpriority value does web.dev suggest for carousel images that aren't visible when the page loads?

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

            Exercise 6 · ChallengePreload by header

            Besides a preload in the HTML, which HTTP response header can preload the LCP image?

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

              Check your understanding

              For each question, ask when the browser could first find the photo, at what priority it asked, and where the wait landed.

              Load the LCP resource early quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What is the second line this discovery example prints?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // How long after the page's first file each version started loading the photo, in ms.
                // From the run with the median LCP, of 5.
                const versions = {
                  "An <img> in the HTML": { after: 0.3, lcp: 888 },
                  "A CSS background": { after: 182.6, lcp: 1028 },
                  "Added by a script": { after: 422.8, lcp: 1176 },
                };
                
                for (const [version, { after, lcp }] of Object.entries(versions)) {
                  console.log(`${version}: started ${Math.round(after)} ms after the first file. LCP ${lcp} ms.`);
                }

                Choose an answer to see the explanation.

              2. Question 2 of 7What is the last line this fetchpriority example prints?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // fetchpriority="high" on no image, on the photo alone, and on all seven images.
                // How long the photo took to arrive, and LCP. From the run with the median LCP, in ms.
                const versions = {
                  "On no image": { loadDuration: 382.1, lcp: 888 },
                  "On the photo alone": { loadDuration: 288.3, lcp: 792 },
                  "On all seven images": { loadDuration: 457.3, lcp: 956 },
                };
                
                for (const [version, { loadDuration, lcp }] of Object.entries(versions)) {
                  console.log(`${version}: the photo took ${Math.round(loadDuration)} ms to arrive. LCP ${lcp} ms.`);
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7The hero is already an <img> with its src in the HTML. Which change makes Chrome ask for it at High from the start?

                Choose an answer to see the explanation.

              4. Question 4 of 7A script adds the hero. A preload brings its load delay down to about 10 ms. What happens to element render delay?

                Choose an answer to see the explanation.

              5. Question 5 of 7The hero came from another origin, and its <img> was in the HTML. Why didn't a preconnect help?

                Choose an answer to see the explanation.

              6. Question 6 of 7What did adding Timing-Allow-Origin to the photo's response change?

                Choose an answer to see the explanation.

              7. Question 7 of 7By web.dev's test, when should the LCP resource start loading?

                Choose an answer to see the explanation.

              Key takeaways

              • The LCP image should start loading with the page's first file. Any later is waiting.
              • Name it in the HTML as an <img> with a src, so the preload scanner finds it at once.
              • Mark it fetchpriority="high", and only it. On every image, the hint backfires.
              • If only CSS or a script names it, preload it with fetchpriority="high", then check render delay.
              • Never lazy-load it, with the attribute or with a script.
              • Serve it from your own origin. If it must come from another, send Timing-Allow-Origin so your split is right.

              Remember the one-liner.
              In the HTML, high priority, never lazy, from your own origin.

              Sources

              The facts on this page come from these docs:

              Coming next: Remove render delay, also in Loading content fast: LCP. The photo has arrived, but nothing shows yet. You'll meet what holds it back: render-blocking CSS, pages built by scripts, A/B testing scripts, and late fonts.

              CompleteFrontend Clear concepts. Working examples.