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.
- 01Split LCP into its four partsSay where each part starts and ends, and work the four out from three performance entries.
- 02Find the part that's too bigCompare each part's share with web.dev's guide, and say what a big part points at.
- 03Check 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:
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.
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.
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:
// 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,loadDurationandrenderDelay. - Line 5 adds the four parts up.
- Lines 6 and 7 print
LCP: 520 msandThe 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:
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:
- Asha starts loading the page. This is 0 ms.
- The first byte of the HTML arrives.
- The browser starts loading the photo.
- The last byte of the photo arrives.
- 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:
<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
srctells 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:
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 nourl, so for text,resourcestaysundefined. - 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
requestStartandresponseEnd. Line 7 is moment 5, the LCP entry'sstartTime. - 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:
// 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 LCPandloadDelay: 4 ms, 1% of LCP. - Then
loadDuration: 206 ms, 40% of LCPandrenderDelay: 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.
- Time to first byte307 ms, 59% (guide about 40%)
- Resource load delay4 ms, 1% (guide under 10%)
- Resource load duration206 ms, 40% (guide about 40%)
- Element render delay3 ms, 1% (guide under 10%)
- LCP, all four together520 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.
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:
<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. Itsdefertells 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.
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:
// 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.andThe 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:
<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-hidegetsopacity: 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.
asynctells 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.
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 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.
| If this part is big | It usually means | Look at |
|---|---|---|
| Time to first byte | The page's HTML came late. | The server, redirects, and how far away the server is. |
| Resource load delay | The browser found the photo late. | Whether the photo is in the HTML, or only a script or stylesheet adds it. |
| Resource load duration | The photo took long to download. | The photo's size, and how far it has to travel. |
| Element render delay | The 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:
// 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%)andphoto added by a script: LCP 772 ms, loadDelay 34% (guide 10%). - The last two are
heavy photo: LCP 996 ms, loadDuration 68% (guide 40%)andhidden 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 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)andhidden 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:
// 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 printsRender 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.
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
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":
- Eliminate resource load delay.
- Eliminate element render delay.
- Reduce resource load duration.
- 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,resourceLoadDurationandelementRenderDelay. - 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 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.andThe 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.
- 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
Sort each problem by the part of LCP it makes longer.
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.
What does the second line print?
// 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`);
}loadDelay: 259 ms, 34% of LCP. A third of the LCP had passed before the photo was even asked for.
What does the last line print?
// 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]}%)`);
}hidden, light photo: LCP 2136 ms, renderDelay 76% (guide 10%). The photo arrived sooner, so render delay took an even bigger share of the same LCP.
On the slow-server page, what share of LCP was resource load duration?
12%. The photo's download took 205 ms, about the same as on the healthy page, where it was 40% of LCP. Its share fell because TTFB grew, not because the download got faster.
On the heavy-photo page, what share of LCP was time to first byte?
31%. TTFB took about the same 306 ms as on the healthy page. It's a smaller share of a longer LCP.
In the CrUX API, what's the metric name for the LCP image's resource load delay?
largest_contentful_paint_image_resource_load_delay. The other three parts have names like it.
In the web-vitals attribution build, which LCP attribution field holds the LCP resource's Resource Timing entry?
lcpResourceEntry: the LCP photo's resource entry, the record that gives moments 3 and 4.
Check your understanding
For each question, ask which two moments a part sits between, and whether a fix made LCP itself shorter.
Question 1 of 7What does the last line of the first example print?
Read the code, then predict// 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.
Question 2 of 7What does the first line of the shares example print?
Read the code, then predict// 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.
Question 3 of 7In this example, what does the third line print?
Read the code, then predict// 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.
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.
Question 5 of 7What is resource load delay?
Choose an answer to see the explanation.
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.
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:
- Optimize Largest Contentful Paint, web.dev.
- web-vitals: src/attribution/onLCP.ts, Google Chrome on GitHub.
- Resource Timing: cross-origin resources, W3C.
- The script element: defer and async, HTML Standard, WHATWG.
- Largest Contentful Paint (LCP), web.dev.
- What's new in CrUX: February 2025, Chrome for Developers.
- Common misconceptions about how to optimize LCP, web.dev.
- LCP breakdown, Chrome for Developers.
- web-vitals: LCP attribution, Google Chrome on GitHub.
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.