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.
- 01Get 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.
- 02Give it the right priorityMark the LCP image alone with fetchpriority=high, and never lazy-load it.
- 03Weigh 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:
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:
// 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>carriesfetchpriority="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.andThe 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.
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 itssrcin 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 LCP image should start loading together with the first file the page loads. Every millisecond after that is waiting. Five habits get it there:
- Name it in the HTML, as an
<img>with asrc. - Mark it
fetchpriority="high", and only it. - If only the stylesheet or a script names it, preload it.
- Never lazy-load it.
- 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.
- The hero startedat 487 ms, 0 ms after the first file
- Chrome's priority for itMedium → High
- Time to first byte481 ms
- Resource load delay12 ms
- Resource load duration382 ms
- Element render delay13 ms
- LCP888 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.
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:
<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
srcnames 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:
.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:
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:
// 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
afteris 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.
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 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:
// 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.andOn 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:
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:
<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.hrefis the photo's exact address, andas="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 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 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.
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.
| Tool | What it changes | When 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.
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:
<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 insrc, but the browser waits before using it. - Line 2 is for a lazy-loading script. The address is in
data-srcinstead ofsrc. Adata-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:
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-srcintosrc. 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:
// 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.
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:
// 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 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.
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.
- 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
Sort each choice by whether it gets the LCP image started early or holds it back.
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.
The course added the preloaded CSS background to the discovery example. What does the last line print?
// 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.`);
}A CSS background, preloaded: started 0 ms after the first file. LCP 792 ms. With the preload, the photo was the page's very first file, so it started with nothing before it.
These are the four parts of the version with a lazy-loading script. What does the last line print?
// 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.`);LCP: 1160 ms. Load delay is 406 ms of it: the photo waited for the whole script before it was even asked for.
With the hero as a CSS background, which priority did Chrome give it when it asked for it, and which did it end with?
Low → High. Images start at Low, and Chrome raises the ones it finds on the screen at layout. A photo named only in CSS starts late, and at the low label too.
The preconnected version didn't send Timing-Allow-Origin. How many milliseconds was the hero's resource load duration?
618 ms. That version sent no Timing-Allow-Origin header, so the new connection is counted inside load duration, as on the version without a preconnect (615 ms).
Which fetchpriority value does web.dev suggest for carousel images that aren't visible when the page loads?
fetchpriority="low", so the hidden slides leave the link to the photos people can see.
Besides a preload in the HTML, which HTTP response header can preload the LCP image?
A Link header. The browser reads it with the response's headers, before the HTML itself.
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.
Question 1 of 7What is the second line this discovery example prints?
Read the code, then predict// 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.
Question 2 of 7What is the last line this fetchpriority example prints?
Read the code, then predict// 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.
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.
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.
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.
Question 6 of 7What did adding Timing-Allow-Origin to the photo's response change?
Choose an answer to see the explanation.
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:
- Optimize Largest Contentful Paint, web.dev.
- Optimize resource loading with the Fetch Priority API, web.dev.
- data-* attributes, MDN.
- IntersectionObserver, MDN.
- Timing-Allow-Origin, MDN.
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.