completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

The four parts of LCP

Split LCP into time to first byte, resource load delay, resource load duration and element render delay, then fix the part that's too big. Six pages loaded in Chrome on a Slow 4G link: a healthy one, a slow server, a photo added by a script, a heavy photo, and a page hidden by an A/B test, where a lighter photo saved nothing.

By the end, you can
  • 01
    Split LCP into its four partsSay where each part starts and ends, and work the four out from three performance entries.
  • 02
    Find the part that's too bigCompare each part's share with web.dev's guide, and say what a big part points at.
  • 03
    Check that a fix lowers LCPSee how a shorter part can just move its time into the next one, and which fix to try first.

Four parts that add up to LCP

Asha opens the Leaf and Kettle tea shop's spring sale page on her phone. A line of text appears first: "Everything in the shop is 20% off until Sunday." Then a big photo of mint tea fills the screen. The photo is the largest thing she sees. So the moment it's painted is the page's Largest Contentful Paint, or LCP. How LCP works showed how the browser picks that element and times it.

Here is what Asha's screen showed, moment by moment, from the moment she opened the page:

What Asha's screen showed on the healthy page. At 0 ms, Asha opens the page: a blank screen. At 307 ms, the first byte arrives: a blank screen. At 311 ms, the photo is asked for: a blank screen. At 517 ms, the photo's last byte arrives: the line of text, and no photo yet. At 520 ms, the photo is painted: LCP: the line of text and the photo. Between the screens are the four parts: TTFB 307 ms, load delay 4 ms, load duration 206 ms, render delay 3 ms.0 msblankAsha opensthe page307 msblankthe first bytearrives311 msblankthe photo isasked for517 msThe line of text, and no photo yetthe photo's lastbyte arrives520 msThe line of text and the mint tea photothe photo ispainted: LCPTTFB307 msload delay4 msload duration206 msrender delay3 ms
Asha's screen at the five moments, on the healthy page. The last two screens are real frames Chrome captured while loading it. The screens are evenly spaced, not to scale. The line of text showed just after the photo was asked for, and the photo only at 520 ms. The gap between two screens is one part of LCP. Shaded: the two parts spent on the network.

For about a third of a second, her screen stayed blank. Then the line of text showed. The photo came last, at the very end. Each gap between two screens is one wait, and this lesson names all four.

Ravi looks after the shop's website. On a slow phone network, the page's LCP was 2,124 ms, a little over two seconds. He wanted it faster, so he tried the obvious fix first. He swapped the photo for one with twelve times fewer bytes. Then he measured again: 2,136 ms. All that work, and LCP didn't get any faster.

Why not? LCP is one number, and one number can't say where its time went. But the wait from opening the page to seeing the photo isn't one long wait. It's four shorter waits, one after another. Find the long one, and you know what to fix. By the end of this lesson, you'll know why Ravi's smaller photo saved nothing.

Real-life analogyA pizza delivery is four waits

You order a pizza. When it finally comes, you can't tell which part was slow. First the shop answers the phone and takes your order. Then your order waits until the cook starts on it. Then the pizza bakes in the oven. Then the driver brings it over and knocks on your door. Each wait starts the moment the one before it ends, so the four add up to your whole wait.

In real life: The shop answering and taking your order
In JavaScript: Time to first byte: the page's HTML starts to arrive
In real life: Your order waiting before the cook starts on it
In JavaScript: Resource load delay: the photo isn't asked for yet
In real life: The pizza in the oven
In JavaScript: Resource load duration: the photo downloading
In real life: The drive, and the knock at your door
In JavaScript: Element render delay: the photo has arrived but isn't on screen yet

Loading the photo goes the same way. The browser waits for the page's HTML to start arriving. It finds the photo in the HTML and asks for it. The photo downloads. Then the browser paints it. web.dev, the Chrome team's site for web developers, gives each wait a name.

The main idea

LCP is four parts, back to back: time to first byte (TTFB), resource load delay, resource load duration, and element render delay. Find the part that's too big, fix it, then check that LCP itself went down.

To see the four parts for real, the course built six versions of the spring sale page, all with the same photo. One is healthy. Each of the others has something wrong. Two are Ravi's page, before and after his swap. Chrome loaded each version 5 times, in an 800 by 600 window. The link was as slow as a slow 4G phone network: 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 is Asha's visit to the healthy version:

Four parts, one LCPPop out in the code editor (opens in a new tab)JavaScript
// Asha's visit to the healthy page, in ms: Chrome's LCP, and its four parts.const lcp = 520;const parts = { ttfb: 307.1, loadDelay: 3.5, loadDuration: 206.1, renderDelay: 3.3 }; const sum = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay;console.log(`LCP: ${lcp} ms`);console.log(`The four parts add up to ${sum.toFixed(1)} ms.`);
  • Line 1 is a comment. Times are in milliseconds (ms), thousandths of a second. This visit's LCP was the median of the 5 runs: the middle one, if you line them up from fastest to slowest.
  • Line 2 is the LCP Chrome reported for this visit.
  • Line 3 holds the four parts, measured on the same visit. Their short names are ttfb, loadDelay, loadDuration and renderDelay.
  • Line 5 adds the four parts up.
  • Lines 6 and 7 print LCP: 520 ms and The four parts add up to 520.0 ms.

The parts add up to the LCP exactly. web.dev: "There is no overlap or gap between them. Collectively, they add up to the full LCP time." Here is the same visit as a picture, one bar per part:

The healthy page's four parts, from the run with the median LCP, 520 ms. Time to first byte: 307 ms, from 0 ms. Resource load delay: 4 ms, from 307 ms. Resource load duration: 206 ms, from 311 ms. Element render delay: 3 ms, from 517 ms.Time to first byte307 msResource load delay4 msResource load duration206 msElement render delay3 ms0 msLCP, 520 ms
Each part starts where the one before it ends, so together they make the whole LCP. Shaded: the two parts spent on the network. Plain: the two delays.

Two bars are long: the network at work, fetching the HTML's first byte and then the photo. The two delays are so thin you can barely see them. On a healthy page, that's the shape you want.

The four parts

Each part starts at one moment and ends at the next. Five moments mark them:

  1. Asha starts loading the page. This is 0 ms.
  2. The first byte of the HTML arrives.
  3. The browser starts loading the photo.
  4. The last byte of the photo arrives.
  5. The photo is painted on the screen.

Each part is the gap between two of these moments, the way each job in The navigation timeline was the gap between two times. Here they are one at a time, on the healthy page.

Time to first byte: the page starts to arrive

The first part runs from moment 1 to moment 2. web.dev defines it as "the time from when the user initiates loading the page until the browser receives the first byte of the HTML document response." In the pizza story, it's the shop answering the phone and taking your order.

On the healthy page, it took 307.1 ms, most of the LCP. Was the server slow? No. The network was. Opening a connection to the server costs one round trip, 150 ms. Sending the request and getting the first byte back costs another. That's 300 ms before anything else. It leaves about 7 ms for the rest, the server's own work included.

Time to first byte took this part apart. For LCP, what matters is that it comes first, so a late first byte pushes every later moment back.

Resource load delay: before the photo is asked for

The second part runs from moment 2 to moment 3. A resource is any file the page loads, like an image or a font. So resource load delay is the wait before the LCP element's file is even asked for.

To ask for the photo, the browser has to find it in the HTML. On the healthy page, the <img> tag sits right after one line of text:

The healthy page's photo, in its HTMLHTML
<p id="intro">Everything in the shop is 20% off until Sunday.</p>
<img id="hero" src="/hero.jpg" alt="Mint tea" width="640" height="427" fetchpriority="high">
  • Line 1 is the line of text Asha sees first.
  • Line 2 is the photo. Its src tells the browser which file to load: /hero.jpg.

The browser read line 2 and asked for the photo 3.5 ms after the first byte. In the pizza story, this is your order waiting before the cook starts on it. Nothing useful happens during it.

If the LCP element is text in a system font, one already installed on the device, there's no file to load. Then, web.dev says, "this time is 0."

Resource load duration: the photo's download

The third part runs from moment 3 to moment 4: from the request for the photo until its last byte arrives. web.dev calls it "the duration of time it takes to load the LCP resource itself." This is the pizza in the oven.

On the healthy page, it took 206.1 ms. Like the first part, it's the network at work. The request has to reach the server, and the photo's 8,635 bytes have to come back. So it only gets shorter with less work: fewer bytes, or a shorter trip. For text in a system font, it's 0 too.

Element render delay: from arrival to paint

The last part runs from moment 4 to moment 5. To paint the photo means to draw it on the screen, where Asha can see it. web.dev: "The time between when the LCP resource finishes loading and the LCP element rendering fully." This is the drive and the knock at the door.

On the healthy page, it took 3.3 ms: the photo was painted almost the moment it arrived. On one of the broken pages below, the photo arrived and then waited more than a second.

Working out the four parts in JavaScript

The browser doesn't hand you the four parts. You work them out from three performance entries: records the browser keeps of things that happened while the page loaded. The navigation entry records the page's own load. The photo's resource entry records its download. The LCP entry records the paint.

The course's pages split their LCP with this function. It works the way the web-vitals library's attribution build does:

Splitting LCP into four partsJavaScript
function splitLcp(lcp) {  const [navigation] = performance.getEntriesByType("navigation");  const resource = lcp.url ? performance.getEntriesByType("resource").find((entry) => entry.name === lcp.url) : undefined;  const ttfb = navigation.responseStart;  const requestStart = Math.max(ttfb, resource ? resource.requestStart || resource.startTime : 0);  const responseEnd = Math.max(requestStart, resource ? resource.responseEnd : 0);  const renderTime = Math.max(responseEnd, lcp.startTime);  return { ttfb, loadDelay: requestStart - ttfb, loadDuration: responseEnd - requestStart, renderDelay: renderTime - responseEnd };}
  • Line 1: the function gets the LCP entry, lcp. Line 2 gets the navigation entry.
  • Line 3 finds the photo's resource entry: the one whose address matches the LCP entry's url. Text has no url, so for text, resource stays undefined.
  • Line 4 is moment 2, the navigation entry's responseStart. Every time counts from moment 1, so it's the TTFB itself.
  • Lines 5 and 6 are moments 3 and 4: the photo's requestStart and responseEnd. Line 7 is moment 5, the LCP entry's startTime.
  • Line 8 subtracts each moment from the next one. That gives the four parts.

Each Math.max stops a moment from coming before the one before it, so no part goes below 0. For text, lines 5 and 6 both fall back to the TTFB, so both load parts come out as 0.

Line 5 also has a backup. A photo can come from another origin: another scheme, host or port than the page's. Then the Resource Timing standard sets its requestStart to zero, unless the photo's server sends a Timing-Allow-Origin header. So the code falls back to startTime, when the browser began to fetch the photo. The web-vitals source says: "Prefer `requestStart` (if TOA is set), otherwise use `startTime`."

Healthy proportions

On the healthy page, two parts were big and two were tiny. web.dev points at a clue in the names: "Of the four subparts, two have the word "delay" in their names. That is a clue that you want to get these times as close to zero as possible. The other two parts involve network requests, which by their very nature take time."

Think back to the pizza. Taking the order and baking are real work, and only get shorter with less to do. An order waiting for the cook, or a baked pizza waiting for the driver, is time where nothing happens. That time is pure loss.

So instead of milliseconds, look at each part's share: how much of the LCP it took, as a percentage. This code works out the healthy page's shares:

Each part's share of LCPPop out in the code editor (opens in a new tab)JavaScript
// The healthy page's four parts, in ms, from the same visit.const parts = { ttfb: 307.1, loadDelay: 3.5, loadDuration: 206.1, renderDelay: 3.3 };const lcp = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay; for (const [part, ms] of Object.entries(parts)) {  console.log(`${part}: ${Math.round(ms)} ms, ${Math.round((ms / lcp) * 100)}% of LCP`);}
  • Line 2 holds the four parts from the visit you've been following.
  • Line 3 adds them up to get the LCP.
  • Lines 5 to 7 go through the parts one at a time. Line 6 prints each one in ms, and as a share of the LCP.
  • It prints ttfb: 307 ms, 59% of LCP and loadDelay: 4 ms, 1% of LCP.
  • Then loadDuration: 206 ms, 40% of LCP and renderDelay: 3 ms, 1% of LCP.

web.dev gives a guide for a well-optimized page:

  • Time to first byte: about 40% of LCP.
  • Resource load delay: under 10%.
  • Resource load duration: about 40%.
  • Element render delay: under 10%.

The healthy page spent 59% on TTFB, well over 40%. Is that a problem? No. web.dev: "Note that these time breakdowns are guidelines, not strict rules." Here the photo was small, and the first byte cost two round trips. What matters is the two delays: 1% each.

And don't turn the shares into millisecond targets. web.dev: "These subparts are only meaningful relative to each other, so it's best to always measure them that way." Why it matters: a share tells you which part to look at first, not how fast the page should be.

Which part is too big?

Now break the page on purpose. Each of the four broken versions has one problem. Guess which part each problem makes longer, then pick a page in the playground and check. The marked row is the part that grew most, compared with the healthy page.

Playground: six pages, four parts
The four parts
  1. Time to first byte307 ms, 59% (guide about 40%)
  2. Resource load delay4 ms, 1% (guide under 10%)
  3. Resource load duration206 ms, 40% (guide about 40%)
  4. Element render delay3 ms, 1% (guide under 10%)
  5. LCP, all four together520 ms
Try it yourself
A healthy page: LCP 520 ms

These parts come from the run whose LCP was the middle one of 5, so they add up to its LCP. Both delays are close to zero. Nearly all the time went on the HTML's first byte and the photo's download.

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

A slow server makes time to first byte longer

On the first broken page, the server waits 1.2 seconds before it sends the HTML. That's what a slow database query does while the server builds a page. TTFB went from 307 ms on the healthy page to 1,507 ms. That's 1,200 ms more: the server's wait, almost to the millisecond.

The other three parts stayed within 10 ms of the healthy page's. It's a pizza shop that answers the phone late: everything after starts late, but takes as long as ever. Why it matters: when TTFB is big, the fix is on the server, or on the road to it.

A photo added by a script makes resource load delay longer

On the second broken page, the photo isn't in the HTML at all. A script adds it. Here are three lines from its HTML:

The page whose photo a script adds (three lines)HTML
<script src="/app.js" defer></script>
<p id="intro">Everything in the shop is 20% off until Sunday.</p>
<div id="root"></div>
  • Line 1 loads the script, app.js. Its defer tells the browser to run the script once it has read the whole HTML.
  • Line 2 is the same line of text as before.
  • Line 3 is an empty box: a <div> with nothing inside. There's no <img> anywhere.

The script holds React's production build, about 18 KB, then one line that puts the <img> into the empty box. So the browser has to download and run the whole script before it knows there's a photo. Resource load delay went from 3.5 ms to 259.3 ms.

Real-life analogyFinding out you need saffron halfway through a recipe

You start cooking a new recipe. Halfway down, it says: add a pinch of saffron. You don't have any, so you stop and go to the shop. The trip takes as long as ever. It just starts late, because you only found out halfway through.

In real life: The recipe you read from the top
In JavaScript: The page's HTML, read as it arrives
In real life: Saffron mentioned only halfway down
In JavaScript: A photo that only a script adds, after the script downloads and runs
In real life: The trip to the shop starting late
In JavaScript: A long resource load delay
In real life: Saffron on the shopping list at the top
In JavaScript: An <img> tag right in the HTML

Why it matters: web.dev lists this among the cases where the browser can't find the LCP image by scanning the HTML. A CSS background image is another. The fix, putting the photo in the HTML, is the next lesson.

A heavy photo makes resource load duration longer

The third broken page shows the photo at the same size on screen. But its file is 1600 by 1067 and 103,126 bytes instead of 640 by 427 and 8,635. How long should those bytes take? A byte is 8 bits, so 1.6 megabits a second is 200 bytes every millisecond:

How long the bytes take on the linkPop out in the code editor (opens in a new tab)JavaScript
// The link carries 1.6 megabits a second: 200 bytes every millisecond.const bytesPerMs = 200;const lightPhoto = 8635;const heavyPhoto = 103126; console.log(`The light photo needs ${(lightPhoto / bytesPerMs).toFixed(1)} ms on the link.`);console.log(`The heavy photo needs ${(heavyPhoto / bytesPerMs).toFixed(1)} ms on the link.`);
  • Line 2 is the link's speed, in bytes per millisecond.
  • Lines 3 and 4 are the two photos' sizes, in bytes.
  • Lines 6 and 7 divide each size by the speed. They print The light photo needs 43.2 ms on the link. and The heavy photo needs 515.6 ms on the link.

The difference is about 472 ms. Now compare the recordings. Resource load duration went from 206.1 ms to 677.8 ms: 471.7 ms more. So the extra bytes explain almost all of the extra time. A bigger order spends longer in the oven.

Why it matters: a smaller file shrinks this part. So do, web.dev says, a shorter trip, fewer files loading at once, or a copy from the cache.

A hidden page makes element render delay longer

The last broken page runs an A/B test: some visitors see one version of the page, and others another. A testing company's script picks the version. Until it has, the page stays hidden. Three lines in the <head> do the hiding:

Hiding the page until the testing script runsHTML
<style>.ab-hide { opacity: 0; }</style>
<script>document.documentElement.classList.add("ab-hide");</script>
<script src="https://ab-tests.example/ab-test.js" async></script>
  • Line 1 is a CSS rule. Anything with the class ab-hide gets opacity: 0, which makes it fully see-through.
  • Line 2 adds that class to the <html> element, so the whole page is hidden at once.
  • Line 3 loads the testing company's script from its own address. async tells the browser to run it as soon as it arrives.

The course's stand-in for that script has one line, and it removes the class again: document.documentElement.classList.remove("ab-hide"). Only then can Asha see anything. The course made the testing company's server take 1.5 seconds to send the script. And it comes from another address, so the browser first had to open a new connection to it.

Meanwhile, the photo downloaded as usual. Its last byte was in at about 988 ms. But Chrome doesn't count "Elements with an opacity of 0, that are invisible to the user". So the photo sat there, ready and unseen, until the script ran. Element render delay went from 8.9 ms on the heavy-photo page to 1,135.7 ms.

Real-life analogyA parcel on the doorstep, and nobody hears the bell

A courier brings your parcel and rings the bell. You're out in the garden, so you don't hear it. The parcel sits on the doorstep. It has arrived, but you don't have it. It only reaches you when you come back inside and open the door.

In real life: The parcel left on the doorstep
In JavaScript: The photo's last byte arriving
In real life: Being out in the garden
In JavaScript: The page hidden by the A/B test
In real life: Coming back in and opening the door
In JavaScript: The testing script running and removing the class
In real life: The time the parcel sat outside
In JavaScript: Element render delay

Why it matters: a big render delay means the photo was ready and something else held it back. web.dev lists the usual causes:

  • A stylesheet or script in the <head> is still loading, and it blocks painting.
  • The script that adds the photo's element hasn't run yet.
  • Other code hides the element, like this A/B test.
  • The main thread, where scripts run and images are rendered, is busy with long tasks.

All five pages side by side

Here are the healthy page and the four broken ones. Each bar is one page's LCP, cut into its four parts:

Each page's LCP as its four parts in order, from the run with the median LCP. A healthy page: 520 ms. A slow server: 1,720 ms: TTFB 1,507 ms. The photo added by a script: 772 ms: load delay 259 ms. A heavy photo: 996 ms: load duration 678 ms. Hidden by an A/B test, heavy photo: 2,124 ms: render delay 1,136 ms.A healthy page520 msA slow server1,720 ms: TTFB 1,507 msThe photo added by a script772 ms: load delay 259 msA heavy photo996 ms: load duration 678 msHidden by an A/B test, heavy photo2,124 ms: render delay 1,136 ms
Each bar is one page's LCP, cut into TTFB, load delay, load duration and render delay, in that order. Shaded: the part that grew, compared with the healthy page at the top.

Each problem landed in its own part, and the other three stayed about the same. So the big part tells you where to look. Read this table across: each row is one part, what a big value means, and where to look.

What a big part points at
If this part is bigIt usually meansLook at
Time to first byteThe page's HTML came late.The server, redirects, and how far away the server is.
Resource load delayThe browser found the photo late.Whether the photo is in the HTML, or only a script or stylesheet adds it.
Resource load durationThe photo took long to download.The photo's size, and how far it has to travel.
Element render delayThe photo arrived, but wasn't painted yet.Stylesheets and scripts that block painting, and code that hides the page.

Comparing with the guide

A real page doesn't come with a healthy twin to compare against. So use web.dev's guide instead. This code finds the part that's furthest over its guide on each broken page:

Which part is furthest over the guide?Pop out in the code editor (opens in a new tab)JavaScript
// Four broken pages: each one's four parts in ms, from the run with the median LCP.const guide = { ttfb: 40, loadDelay: 10, loadDuration: 40, renderDelay: 10 }; // web.dev's guide, in % of LCPconst pages = {  "slow server": { ttfb: 1506.8, loadDelay: 3.3, loadDuration: 204.9, renderDelay: 5 },  "photo added by a script": { ttfb: 304.4, loadDelay: 259.3, loadDuration: 205.5, renderDelay: 2.8 },  "heavy photo": { ttfb: 305.9, loadDelay: 3.4, loadDuration: 677.8, renderDelay: 8.9 },  "hidden by an A/B test": { ttfb: 305.7, loadDelay: 6.1, loadDuration: 676.5, renderDelay: 1135.7 },}; for (const [page, parts] of Object.entries(pages)) {  const lcp = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay;  const share = (part) => Math.round((parts[part] / lcp) * 100);  const over = (part) => share(part) - guide[part];  const worst = Object.keys(parts).reduce((a, b) => (over(b) > over(a) ? b : a));  console.log(`${page}: LCP ${Math.round(lcp)} ms, ${worst} ${share(worst)}% (guide ${guide[worst]}%)`);}
  • Line 2 is web.dev's guide, in % of LCP.
  • Lines 4 to 7 hold each broken page's four parts, from the run with the median LCP.
  • Line 11 adds up the LCP. Lines 12 and 13 work out each part's share, and how far it is over its guide.
  • Line 14 keeps the part furthest over. Line 15 prints it.
  • The first two lines it prints are slow server: LCP 1720 ms, ttfb 88% (guide 40%) and photo added by a script: LCP 772 ms, loadDelay 34% (guide 10%).
  • The last two are heavy photo: LCP 996 ms, loadDuration 68% (guide 40%) and hidden by an A/B test: LCP 2124 ms, renderDelay 53% (guide 10%).

The guide found the same part as the healthy twin, every time. Now look at the last line: LCP 2,124 ms. That's Ravi's page. Its problem wasn't the photo's download. It was element render delay.

Fix one part, check LCP

Now back to Ravi. The course tried his swap, heavy photo to light, on two pages: the visible heavy-photo page, and his hidden one.

The same lighter photo on two pagesPop out in the code editor (opens in a new tab)JavaScript
// The median LCP of 5 runs, in ms, with the heavy photo and with the light one.const pages = {  "visible page": { heavy: 996, light: 520 },  "hidden by an A/B test": { heavy: 2124, light: 2136 },}; for (const [page, { heavy, light }] of Object.entries(pages)) console.log(`${page}: ${heavy} → ${light} ms (${light > heavy ? "+" : ""}${light - heavy} ms)`);
  • Lines 3 and 4 are recorded: each page's median LCP of 5 runs, with each photo.
  • Line 7 prints the change on each page: visible page: 996 → 520 ms (-476 ms) and hidden by an A/B test: 2124 → 2136 ms (+12 ms).

On the visible page, the lighter photo saved nearly half a second. On Ravi's page, it saved nothing: 12 ms is within the normal spread between runs. So where did the time go? Compare the hidden page's last two parts, before and after:

Where the saved time wentPop out in the code editor (opens in a new tab)JavaScript
// The hidden page's last two parts, in ms, with the heavy photo and with the light one.const heavy = { loadDuration: 676.5, renderDelay: 1135.7 };const light = { loadDuration: 206, renderDelay: 1614.4 }; console.log(`Load duration fell by ${Math.round(heavy.loadDuration - light.loadDuration)} ms.`);console.log(`Render delay grew by ${Math.round(light.renderDelay - heavy.renderDelay)} ms.`);
  • Lines 2 and 3 hold the two parts from each page's run with the median LCP.
  • Line 5 prints Load duration fell by 471 ms. Line 6 prints Render delay grew by 479 ms.

The photo arrived 471 ms sooner, and then waited 479 ms longer. The page still waited for the testing script, just as before. The time Ravi saved just moved from one part into the next.

Real-life analogyHurrying to a bus that leaves on time

You walk faster than usual and reach the bus stop early. But the bus leaves at the time on the timetable, as always. You don't get to work any sooner. The minutes you saved walking, you spend standing at the stop.

In real life: Walking to the bus stop
In JavaScript: Resource load duration: the photo's download
In real life: Standing at the stop
In JavaScript: Element render delay: the photo waits, hidden
In real life: The bus timetable
In JavaScript: The testing script, which shows the page when it runs
In real life: Getting to work
In JavaScript: LCP: the photo is painted
The same lighter photo on two pages, from the runs with the median LCP. Visible page, heavy photo: LCP 996 ms, load duration 678 ms. Visible page, light photo: LCP 520 ms, load duration 206 ms. Hidden by an A/B test, heavy photo: LCP 2,124 ms, load duration 677 ms. Hidden by an A/B test, light photo: LCP 2,136 ms, load duration 206 ms.Visible page, heavy photoLCP 996 ms, load duration 678 msVisible page, light photoLCP 520 ms, load duration 206 msHidden by an A/B test, heavy photoLCP 2,124 ms, load duration 677 msHidden by an A/B test, light photoLCP 2,136 ms, load duration 206 ms
Shaded: load duration. The lighter photo shortened it on both pages. Only the visible page's bar got shorter. On the hidden page, render delay grew instead.

web.dev warns: "In some cases, an optimization applied to one part won't improve LCP, it will just shift the time saved to another part." Its example is a page where "the LCP element is hidden until the JavaScript code finishes loading", just like Ravi's. Why it matters: measure LCP itself after every fix, not only the part you changed.

Which fix to try first

So what should Ravi have done? Fix the render delay first: stop hiding the page. Without the hiding, his page is the heavy-photo page, with an LCP of 996 ms. Then the lighter photo counts, as it did there: 520 ms.

web.dev orders its fixes "starting with the optimizations that are likely to have the biggest impact":

  1. Eliminate resource load delay.
  2. Eliminate element render delay.
  3. Reduce resource load duration.
  4. Reduce time to first byte.

The two delays come first, because they're pure waiting. web.dev's test for the first: "your LCP resource should start loading at the same time as the first resource loaded by that page."

The download comes third. When Chrome's team looked at real visits, they found that "image download time is almost never the bottleneck." On the median site with poor LCP, the wait before the image was even asked for was almost four times as long as its download. TTFB comes last because, in web.dev's words, "it's often the one developers have the least control over."

Reading them in your tools

You rarely need to write the split yourself. Most tools show the four parts:

  • The web-vitals library. Its attribution build adds a note to every LCP report: the report card that says why, from The web-vitals library. The note holds timeToFirstByte, resourceLoadDelay, resourceLoadDuration and elementRenderDelay.
  • Chrome DevTools. Record a page load in the Performance panel, and its LCP breakdown insight shows the four parts. See Performance insights.
  • Lighthouse. Its Largest Contentful Paint element diagnostic shows them for the LCP element.

From field data to a fix followed one of these notes like a trail of clues, from the biggest part to the line of code behind it.

Field data has them too. CrUX, the Chrome User Experience Report, collects anonymous timings from real Chrome users. Since February 2025, its API and History API give the four parts, with two rules:

  • "Subparts are only available for images". Pages whose LCP is text are left out.
  • Each part comes as its own 75th percentile: the time that three visits out of four beat.

So, the CrUX team says, "the sum of the subparts aren't expected to equal the overall LCP 75th percentile." Each part's percentile can come from a different visit. A class's middle height and middle weight can belong to two different children. The course's own runs show the same thing with medians:

The medians don't add upPop out in the code editor (opens in a new tab)JavaScript
// The hidden page with the light photo, loaded 5 times: each part's median, in ms.const medians = { ttfb: 306.1, loadDelay: 6.3, loadDuration: 204.8, renderDelay: 1614.4 };const medianLcp = 2136; const sum = medians.ttfb + medians.loadDelay + medians.loadDuration + medians.renderDelay;console.log(`The four medians add up to ${sum.toFixed(1)} ms.`);console.log(`The median LCP was ${medianLcp} ms.`);
  • Line 2 holds each part's median over the 5 runs of the hidden page with the light photo. Line 3 is the median LCP of the same runs.
  • Line 5 adds the four medians.
  • Lines 6 and 7 print The four medians add up to 2131.6 ms. and The median LCP was 2136 ms.

Close, but not equal, even though each run's own parts added up to its LCP. Why it matters: field data's parts show which one is big, "an indication of relative subpart importance", not an exact split.

Which part does it slow?

Each problem below makes one part of LCP longer. Ask when it happens: before the HTML arrives, before the photo is asked for, while it downloads, or after it has arrived.

Which part does it slow?
  • A slow database query while the server builds the HTML
  • A redirect before the page itself
  • An <img> added by JavaScript after a bundle downloads
  • A hero set as a CSS background image
  • A 1600 px wide photo shown at 640 px
  • A photo served from far away, with a long round trip
  • A snippet that hides the page until an A/B testing script runs
  • A render-blocking stylesheet still loading when the photo arrives
Try it yourself
0 of 8 correct

Sort each problem by the part of LCP it makes longer.

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

Common mix-ups

  • "A smaller LCP image always lowers LCP." No. On Ravi's hidden page, LCP went from 2,124 to 2,136 ms. The time saved on the download moved into render delay.
  • "Load delay is the image downloading." No. Load delay is the wait before the request even starts. The download is load duration.
  • "The parts are rough estimates that overlap." No. They sit back to back, with no gap. The healthy visit's parts added up to its 520 ms LCP.
  • "A big TTFB share means a slow server." No. On the healthy page, TTFB was 59% of LCP, and two round trips on a slow link made up nearly all of it.
  • "TTFB should be 40% of 2.5 seconds." No. web.dev advises against turning the shares into milliseconds. They only mean something next to each other.
  • "Render delay always means slow JavaScript." No. On the hidden page, nothing slow ran. The page waited for a script to arrive.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upThe late photo

What does the second line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// The four parts of the page whose photo a script adds, in ms.
const parts = { ttfb: 304.4, loadDelay: 259.3, loadDuration: 205.5, renderDelay: 2.8 };
const lcp = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay;

for (const [part, ms] of Object.entries(parts)) {
  console.log(`${part}: ${Math.round(ms)} ms, ${Math.round((ms / lcp) * 100)}% of LCP`);
}

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

    Exercise 2 · Warm-upHidden, light photo

    What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Five broken pages: each one's four parts in ms, from the run with the median LCP.
    const guide = { ttfb: 40, loadDelay: 10, loadDuration: 40, renderDelay: 10 }; // web.dev's guide, in % of LCP
    const pages = {
      "slow server": { ttfb: 1506.8, loadDelay: 3.3, loadDuration: 204.9, renderDelay: 5 },
      "photo added by a script": { ttfb: 304.4, loadDelay: 259.3, loadDuration: 205.5, renderDelay: 2.8 },
      "heavy photo": { ttfb: 305.9, loadDelay: 3.4, loadDuration: 677.8, renderDelay: 8.9 },
      "hidden by an A/B test": { ttfb: 305.7, loadDelay: 6.1, loadDuration: 676.5, renderDelay: 1135.7 },
      "hidden, light photo": { ttfb: 307.6, loadDelay: 8, loadDuration: 206, renderDelay: 1614.4 },
    };
    
    for (const [page, parts] of Object.entries(pages)) {
      const lcp = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay;
      const share = (part) => Math.round((parts[part] / lcp) * 100);
      const over = (part) => share(part) - guide[part];
      const worst = Object.keys(parts).reduce((a, b) => (over(b) > over(a) ? b : a));
      console.log(`${page}: LCP ${Math.round(lcp)} ms, ${worst} ${share(worst)}% (guide ${guide[worst]}%)`);
    }

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

      Exercise 3 · PracticeSame download, smaller share

      On the slow-server page, what share of LCP was resource load duration?

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

        Exercise 4 · PracticeSame TTFB, smaller share

        On the heavy-photo page, what share of LCP was time to first byte?

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

          Exercise 5 · ChallengeIn CrUX

          In the CrUX API, what's the metric name for the LCP image's resource load delay?

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

            Exercise 6 · ChallengeThe resource entry

            In the web-vitals attribution build, which LCP attribution field holds the LCP resource's Resource Timing entry?

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

              Check your understanding

              For each question, ask which two moments a part sits between, and whether a fix made LCP itself shorter.

              The four parts of LCP quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the last line of the first example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Asha's visit to the healthy page, in ms: Chrome's LCP, and its four parts.
                const lcp = 520;
                const parts = { ttfb: 307.1, loadDelay: 3.5, loadDuration: 206.1, renderDelay: 3.3 };
                
                const sum = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay;
                console.log(`LCP: ${lcp} ms`);
                console.log(`The four parts add up to ${sum.toFixed(1)} ms.`);

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the first line of the shares example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The healthy page's four parts, in ms, from the same visit.
                const parts = { ttfb: 307.1, loadDelay: 3.5, loadDuration: 206.1, renderDelay: 3.3 };
                const lcp = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay;
                
                for (const [part, ms] of Object.entries(parts)) {
                  console.log(`${part}: ${Math.round(ms)} ms, ${Math.round((ms / lcp) * 100)}% of LCP`);
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7In this example, what does the third line print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Four broken pages: each one's four parts in ms, from the run with the median LCP.
                const guide = { ttfb: 40, loadDelay: 10, loadDuration: 40, renderDelay: 10 }; // web.dev's guide, in % of LCP
                const pages = {
                  "slow server": { ttfb: 1506.8, loadDelay: 3.3, loadDuration: 204.9, renderDelay: 5 },
                  "photo added by a script": { ttfb: 304.4, loadDelay: 259.3, loadDuration: 205.5, renderDelay: 2.8 },
                  "heavy photo": { ttfb: 305.9, loadDelay: 3.4, loadDuration: 677.8, renderDelay: 8.9 },
                  "hidden by an A/B test": { ttfb: 305.7, loadDelay: 6.1, loadDuration: 676.5, renderDelay: 1135.7 },
                };
                
                for (const [page, parts] of Object.entries(pages)) {
                  const lcp = parts.ttfb + parts.loadDelay + parts.loadDuration + parts.renderDelay;
                  const share = (part) => Math.round((parts[part] / lcp) * 100);
                  const over = (part) => share(part) - guide[part];
                  const worst = Object.keys(parts).reduce((a, b) => (over(b) > over(a) ? b : a));
                  console.log(`${page}: LCP ${Math.round(lcp)} ms, ${worst} ${share(worst)}% (guide ${guide[worst]}%)`);
                }

                Choose an answer to see the explanation.

              4. Question 4 of 7A team made the LCP photo smaller. Its load duration fell, but LCP didn't. What happened?

                Choose an answer to see the explanation.

              5. Question 5 of 7What is resource load delay?

                Choose an answer to see the explanation.

              6. Question 6 of 7The LCP element is a paragraph in a system font. What are its resource load delay and duration?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why does web.dev advise against turning its percentages into millisecond targets?

                Choose an answer to see the explanation.

              Key takeaways

              • LCP is four parts, back to back: time to first byte, resource load delay, resource load duration, and element render delay.
              • The two delays are pure waiting, so you want them near zero. The other two are network work, and shrink only with less work.
              • The big part points at the fix: the server, how the photo is found, its bytes, or what stops it from showing.
              • A shorter part can just move its time into the next one. Check LCP itself after every fix.
              • The web-vitals library, DevTools, Lighthouse and CrUX all show the four parts.

              Remember the one-liner.
              Split LCP into four parts, fix the one that's too big, and check that LCP went down.

              Sources

              The facts on this page come from these standards and docs:

              Coming next: Load the LCP resource early, also in Loading content fast: LCP. It takes on the first fix on web.dev's list: getting resource load delay down to zero. You'll put the photo in the HTML, give it high priority, and never lazy-load it.

              CompleteFrontend Clear concepts. Working examples.