
Optimizing LCP (Largest Contentful Paint) is not just reducing the file size of your images, there’s a lot more that goes into optimizing the loading strategy – and you can only grasp where the bottleneck is if you understand the sub-parts that make LCP.
LCP is one of the Core Web Vitals metrics that measures when the most meaningful piece of content on the page becomes visible. In practice, this is usually a hero image, banner, or some text element (although the way web is structured these days, it’s mostly an image, or some other media format).
Optimizing LCP matters because users should see this primary content as early as possible.
In thi…

Optimizing LCP (Largest Contentful Paint) is not just reducing the file size of your images, there’s a lot more that goes into optimizing the loading strategy – and you can only grasp where the bottleneck is if you understand the sub-parts that make LCP.
LCP is one of the Core Web Vitals metrics that measures when the most meaningful piece of content on the page becomes visible. In practice, this is usually a hero image, banner, or some text element (although the way web is structured these days, it’s mostly an image, or some other media format).
Optimizing LCP matters because users should see this primary content as early as possible.
In this article I want to address that LCP isn’t a single number. It can be broken down into sub-parts that explain exactly where time is being spent. Understanding these phases is the key to diagnosing slow LCP on sites.
In this post, “LCP sub-parts” refers specifically to image-based LCP candidates, since text-node LCPs don’t involve network duration or delays and aren’t useful for this analysis.
And according to http archive in 2024 – “most LCP elements, or 73% of mobile pages, are images. Interestingly, this percentage is 10% higher on desktop pages.”
LCP is made of four sub-parts:
- TTFB (Time to First Byte)
- Resource Load Delay
- Resource Load Duration
- Element Render Delay
| Dominant LCP Sub-Part | What It Usually Means | What to Fix First | What Won’t Help Much |
|---|---|---|---|
| TTFB | Backend, CDN, or origin latency is the bottleneck | CDN caching, edge rendering, backend optimization, server processing time | Image optimization, fetchpriority |
| Resource Load Delay | LCP resource is discovered too late | Early Hinting, Preload LCP image, fetchpriority="high", remove loading="lazy", avoid CSS bg images | Smaller images, better formats |
| Resource Load Duration | Bytes or network throughput are the issue | Use AVIF/WebP, correct srcset/sizes, CDN image optimization, HTTP 3 | Preload alone, JS refactors |
| Element Render Delay | Rendering is blocked after bytes arrive | Remove blocking JS/CSS, avoid hiding content, render LCP directly in HTML | Faster CDN, smaller images |
This is directional and context-sensitive. Real sites often have multiple overlapping bottlenecks.
Before diving into these phases, let’s establish a quick mental model of how a browser loads a page.
When a user loads a webpage, the browser resolves DNS, establishes a connection, sends the HTTP request, and waits for the server to send the first byte of HTML. The moment that first byte arrives is TTFB. The browser then begins parsing the HTML and building the DOM. While parsing, it encounters many external resources such as stylesheets, images, and scripts. Depending on their type and attributes, the browser may pause parsing or load them in parallel.
Once the browser has enough DOM and CSSOM to render something, it performs layout and paints pixels to the screen. The first time any content appears is FCP (First Contentful Paint). The moment the largest visible (and most significant) element appears is LCP (Largest Contentful Paint). This is also known as the critical rendering path
Between receiving the HTML and painting the largest element, a lot can delay the browser: connection latency, late discovery of the LCP resource, slow image downloads, blocked rendering, and more. The four LCP sub-parts tell us exactly which phase is responsible, and what issues we have to address.
Core Web Vitals consider an LCP under 2.5 seconds “good.” Most tools only show you the final number, but breaking it down into sub-parts can reveal so much about this metric.
Let’s look at each sub-part, how browsers measure them, and how tools like DevTools and RUM surface this data.
Time To First Byte (TTFB)

Time to First Byte is an important sub-part – and usually one of the hardest to optimize without proper infrastructure that allows for CDN and caching.
Google defines TTFB as:
TTFB is a metric that measures the time between starting navigating to a page and when the first byte of a response begins to arrive.
TTFB not only includes the time it takes for the server to process your request and send back the first chunk of HTML but it also includes the time the browser takes to do a DNS lookup, TCP connection setup, and TLS handshakes.
DNS can be cached by the browser, OS, and router layers, so DNS lookups usually aren’t repeated. But connection setup works very differently across HTTP versions.

Connection Setup: Why Protocols Matter (H1 vs H2 vs H3)
Different HTTP versions change how expensive this setup phase is:
HTTP/1.1
Browsers open 6 TCP connections per origin because each connection can handle only one request at a time. This causes request-level head-of-line blocking and repeated TCP slow starts. If idle connections close, new requests pay full DNS + TCP + TLS setup again, which hurts TTFB.
HTTP/2
H2 multiplexing allows many streams over one TCP connection. This eliminates request-level blocking but keeps TCP’s biggest weakness: transport-layer head-of-line blocking. One lost packet stalls all streams. Still, H2 massively reduces request overhead and improves resource delivery.
HTTP/3
H3 is built on QUIC over UDP. QUIC integrates TLS 1.3 and avoids TCP’s ordered-delivery requirement. Packet loss affects only the impacted stream, not the entire connection.
Connection setup is faster, resumption is also faster, and congestion behavior is smoother. Most CDNs and browsers support HTTP/3, and enabling it typically requires one config change.
If the CDN or asset server supports HTTP/2 or HTTP/3, all sub-resources (CSS, JS, images) benefit from multiplexing (H2) and faster connection setup (H3). HTTP/3 in particular reduces handshake latency and avoids TCP-level head-of-line blocking, which can improve delivery.
If you want to learn more on H3 read this detailed blog by Kinsta.
Server Processing Time
After connection setup, actual server work begins: generating HTML, running queries, templates, API calls, business logic, etc. This is often the biggest contributor to TTFB.
Not much can be inferred from the browser because most of the processing happens on the server. One thing that can help us understand what’s contributing to this time is Server Timing Headers. It’s an HTTP response header (Server-Timing) that carries performance data indicating where the server spent time handling the request (database queries, application logic, third-party calls, etc.).
Not all servers expose this header, but if you can control or configure it, it becomes a valuable signal to see server-side bottlenecks. Once you know which layer is slow, you can target it: is it application logic? Database latency? Network calls? CDN or edge logic?
The biggest real-world win usually comes from CDN caching. If a resource is cached at the edge, the CDN can return it quicker without the request ever hitting your origin. This reduces server load and lowers response times by serving prebuilt templates or cached assets. That said, CDN caching strategies are a deeper topic and outside the scope of this article, so I’ll keep it high-level and we’ll re-visit it later.
And once the server finishes processing, it starts streaming to the client, and when the browser receives the first byte – that is labelled as TTFB.
Resource Load Delay

Resource Load Delay is measured from navigation start to resource requestStart.
The browser may not immediately discover the LCP asset while parsing the DOM. Modern websites are bloated with complexity: render-blocking resources, critical CSS, JavaScript execution, and third-party scripts all compete for priority. Every site has its own flavor of problems, but they usually fall into two buckets:
- Other resources are being prioritized over the LCP resource, or
- Render-blocking assets must be downloaded, parsed, and executed before the LCP resource can even be discovered.
If the LCP element is a text node, this value is simply 0, because the browser doesn’t need to fetch any external resource. But most LCP elements on the modern web are images or other media resources, and when the LCP is an external resource, the browser must initiate a network request before it can be rendered.
If the LCP asset is hidden behind indirection, the situation gets worse. For example:
- it’s a CSS background image buried in a stylesheet
- it’s requested by a third-party script
- it’s initiated via JavaScript
- it’s lazily loaded with loading=”lazy”
Any delay before the browser initiates the fetch is what Resource Load Delay measures. For example, you might have a great TTFB, but if the LCP image is a CSS background image, the browser can only request it once it reaches that CSS rule. That’s slow and should be avoided for LCP-critical images.
Another example: setting loading=”lazy” on the LCP image tells the browser to intentionally deprioritize it. This is dangerous for LCP. Your goal is to minimize the time between receiving the first byte of HTML and the moment the browser starts fetching the LCP resource.
One misconception here is that the browser loads resources as it finds them after parsing – this is not the case in modern browsers thanks to preload scanner but a lot of sites end up going against it, or can still use other ways to shrink this time.
Ways to optimize Load Delay
Once you understand what causes Resource Load Delay, the next step is to reduce it. Here are the most effective ways to help the browser discover and fetch your LCP resource earlier:
- Use Early Hints (103) to tell the browser to start loading the LCP image earlier.
- Preload the LCP image using
<link rel="preload">in<head>so the browser treats it as a high-priority resource during parsing and can discover it early. - Set fetchpriority=”high” on the LCP image so the browser knows this image matters more than surrounding assets.
- Avoid indirection. Your LCP candidate should not rely on JavaScript execution, third-party scripts, or deep CSS chains to initiate its load. It should be discovered initially in your page source.
- Avoid large inline scripts in head or early in the body, reduce heavy synchronous JS, and eliminate render-blocking resources to help browsers discover the LCP candidate early.
If you can’t use Early Hints, a combination of preload + fetchpriority=high + eliminating discovery blockers gives you most of the win.
Resource Load Duration

Resource Load Duration is the time it takes for the browser to download the LCP resource after it has initiated the network request. If the LCP element is text, this value is 0 because no external fetch is needed.
Load Duration is basically: (bytes transferred) / (effective throughput), plus a bit of protocol overhead. So you either ship fewer bytes, or get more bytes through faster, or avoid overhead.
At this point, the key variables are:
- how quickly the connection can be established
- how fast the server or CDN can respond
- how large the resource is
- how efficiently it can be encoded
If the image is hosted on a third-party CDN, the browser must perform full connection setup (DNS, TCP/QUIC, TLS), which adds latency before the first byte of the image can arrive. This is why hosting the LCP resource on the same origin, or a warm CDN domain, often leads to better performance.
Delivering the right size
You must also focus on serving the right size and format for your images, webP and AVIF images are significantly smaller in size than a PNG or JPEG, the smaller the size of the resource the faster it’ll take to download it.
If you want real performance wins here with minimal manual effort, do this:
- Pick a build-time tool (Sharp/Image plugins) so every build produces:
- AVIF
- WebP
- A small JPEG/PNG fallback only if needed
- Multiple sizes (400 800 1200 1600)
- Put them behind a smart CDN (Cloudflare/Fastly/Cloudinary) so you never guess formats again.
- Automate quality: aim for perceptual quality ~70–80 for AVIF/WebP. That usually slashes size without visible loss.
If you want less manual work, a CDN with image optimization is a sane choice.
Use responsive delivery correctly
Do not just generate and optimize one single size image, generate a 400, 800, 1200, 1600, etc. to ensure the browser selects the smallest correct one. Modern browsers make this decision before the request is sent, but only if you give them accurate information.
The correct approach is to generate multiple image variants (for example: 400, 800, 1200, 1600 widths) and expose them using either srcset or the <picture> element with explicit format fallbacks:
<picture>
<source
type="image/avif"
srcset="
hero-400.avif 400w,
hero-800.avif 800w,
hero-1200.avif 1200w,
hero-1600.avif 1600w
"
sizes="(max-width: 768px) 100vw, 1200px"
/>
<source
type="image/webp"
srcset="
hero-400.webp 400w,
hero-800.webp 800w,
hero-1200.webp 1200w,
hero-1600.webp 1600w
"
sizes="(max-width: 768px) 100vw, 1200px"
/>
<img
src="hero-1200.jpg"
width="1200"
height="600"
alt="hero"
fetchpriority="high"
/>
</picture>
Two things matter most here:
1. sizes must reflect the actual rendered width
Most sites get this wrong. Setting sizes="100vw" tells the browser the image will always span the full viewport width, even when it doesn’t. The browser then picks a larger candidate than necessary, inflating transfer size and LCP Load Duration.
If your hero image is capped at 1200px on desktop, say so. If it’s smaller in a grid or column layout, say that too.
2. Let the browser pick the variant
Do not hardcode device logic in JavaScript. Do not guess screen sizes server-side. The browser already knows:
- viewport size
- DPR
- layout constraints
- zoom level
Given accurate srcset and sizes, it will reliably choose the optimal resource. If you use an image CDN, the same principles apply. Generate or request width-based URLs (?w=400, ?w=800, etc.) and expose them via srcset.
Element Render Delay

Element Render Delay is the time between when the LCP resource finishes downloading and when the browser is actually able to paint the LCP element on screen. At this phase, the browser already has the bytes, but it isn’t allowed to render them yet.
This delay occurs because rendering depends on the state of the main thread, DOM, CSSOM, and layout. If any step in the critical rendering path is blocked, painting cannot proceed even if the image loaded instantly. Another reason is, if your rendering depends on custom JS logic, or if it’s rendered with a CSS rule the browser will first have to parse that logic before it can render the element.
Common causes include:
- Blocking JavaScript or CSS that must execute or parse before layout
- A/B testing or personalization scripts that hide the page until logic completes
- LCP defined via background-image, which can’t render until relevant stylesheets are parsed
- JavaScript-inserted LCP elements, where the element doesn’t exist in the DOM until JS runs
- Hydration delays in frameworks that defer rendering until the app initializes
In these cases, preload or Early Hints can deliver the LCP bytes early, but render delay nullifies those gains because the browser cannot paint anything until layout and script execution is settled.
If a testing script hides the body for two seconds (which is a common behaviour among many third-party A/B testing scripts!), your LCP is delayed by two seconds – no matter how fast the image downloaded. If the LCP comes from CSS, the browser must parse the stylesheet before it knows how to paint it.
The goal is to give the browser a direct, unblocked path from “bytes arrived” to “paint it.” That means:
- The LCP element should be present directly in HTML, not created via JS or a CSS rule
- Avoid hiding the page with scripts or overlays
- Avoid tying the LCP to background-image rules
- Minimize synchronous JS and blocking CSS
- Avoid component initialization or DOM mutations that gate rendering
All these sub-parts have their own unique problems, and understanding which one is suffering the most can help you identify where you should focus when it comes to optimizing LCP. If any one of them creates a large gap in rendering, no matter how good other sub-parts are – the final LCP will take a hit.
It’s important to understand and optimize each phase, that’s what leads to optimal rendering time for your most significant content on the page. Now let’s talk about how we can use DevTools and RUM to find this data.
Identifying sub-parts using DevTools & RUM
RUM tells you real-user behavior based on data collected from users. DevTools tells you what your page can do in ideal controlled conditions. DevTools is great for LAB testing a single page or set of pages, but RUM field user data can tell the real user story.
Using DevTools
In DevTools you can run a performance trace and the new insights panel will give you LCP sub-parts and help identify which one you should optimize.

- Go to a URL you want to investigate.
- Right click on the page and click on “inspect” to open DevTools.
- Go to Performance Tab and click the reload and record button at top left corner.
- Once the trace is captured open the insights panel from the left, and click on LCP breakdown.
And if you’re interested in reading how the browser renders this data in DevTools you can read the source code.
Using RUM
In a real-user monitoring (RUM) setup, LCP is calculated using the browser’s PerformanceObserver API by listening for largest-contentful-paint entries.
The browser reports when the largest element is painted, along with timing information about that element and its resource.
To understand why LCP is slow, RUM systems combine this LCP entry with Navigation Timing and Resource Timing data to derive the LCP sub-parts such as Time to First Byte, resource load delay, resource load duration, and element render delay.
These sub-parts are not sent directly by the browser as a single payload.
They are computed by correlating multiple performance entries for each navigation.
Conceptually, RUM systems derive:
- TTFB from Navigation Timing (
navigationEntry.responseStart) - Resource Load Delay from:
resource.requestStart - navigationEntry.responseStart - Resource Load Duration from:
resource.responseEnd − resource.requestStart - Element Render Delay from:
LCP.startTime − resource.responseEnd
A full, production-grade implementation involves edge cases such as soft navigations, text-based LCP elements, and buffering behavior, which are outside the scope of this article.
If you’re interested in a deeper dive into how this is implemented in real RUM systems, let me know and I can cover it in a follow-up post.
And at Yottaa, we collect these LCP sub-parts via RUM at scale and use them to understand which phase of LCP is limiting real user experiences.
LCP problems rarely come from “slow images” in general. They come from a specific phase in the loading and rendering pipeline being blocked or delayed.
By breaking LCP into sub-parts, you can identify that phase precisely and fix the real bottleneck instead of optimizing in the dark.
Once you know which sub-part dominates, the next step becomes obvious and you can stop spending time on changes that won’t move the needle.
Also if you’re interested in reading state of LCP and sub-parts data for 2024 by HTTPArchive I highly recommend checking it out here.