First, establish what the metric is measuring
LCP is the render time of the largest content element visible in the viewport, measured from navigation start. The candidate can be an <img>, an image inside an <svg>, a video's poster frame, an element whose background is a CSS url() image, or a block-level element containing text. It is a moving target during load: the browser reports a new candidate every time a larger one paints, and the final value is the last candidate reported before the user first interacts or the page is hidden. Good is 2.5 seconds or less at the 75th percentile of page loads, poor is above 4 seconds, and the band between the two is "needs improvement".
The first diagnostic question is therefore not "how do I make images faster" but "which element is the LCP element, and is it the element I want it to be?" A page whose LCP candidate turns out to be a cookie banner or a paragraph of boilerplate has a different problem from one whose LCP is a legitimate hero image.
Split it into four sub-parts
Every LCP decomposes into four consecutive spans, and they are mutually exclusive, so they sum to the whole metric.
- Time to first byte — navigation start until the first byte of the document arrives. Server think time, redirects, DNS and TLS, and cache misses all land here.
- Resource load delay — first byte until the browser starts fetching the LCP resource. This is pure discovery latency: the image existed all along, but the browser did not know to ask for it yet. Zero for a text LCP.
- Resource load time — how long the fetch itself takes. Bytes over the wire.
- Element render delay — the resource finished downloading, or the text was ready, but nothing painted yet. Render-blocking stylesheets, synchronous scripts, blocked font loads and long main-thread tasks all sit here.
web.dev suggests treating roughly 40% TTFB, 10% load delay, 40% load time and 10% render delay as the target shape. The exact split matters less than the discipline: find the dominant span first, because a fix aimed at the wrong span produces no measurable change.
Get the breakdown from the field, not just from lab
Lab and field data disagree constantly, and usually for legitimate structural reasons rather than because one is wrong. Lighthouse runs one cold load, with an empty cache, on one device profile, under simulated network throttling, and never scrolls or interacts. CrUX field data is the 75th percentile over a rolling window of real loads, mixing warm and cold caches, real devices from cheap Android upward, real networks, and users who scroll and click. So a page can score well in Lighthouse and fail in the field because the p75 device is far slower than the lab profile, or score badly in Lighthouse and pass in the field because most real visits arrive with the hero image already cached.
There is also a metric that only exists in the field. INP replaced FID as a Core Web Vital in March 2024, and it requires real interactions, so Lighthouse cannot report it at all — it reports Total Blocking Time as a lab proxy. Anyone who says "Lighthouse showed our INP is fine" has told you they have not looked.
So collect the sub-part attribution from real users with the web-vitals attribution build, which reports the LCP element, its URL and each of the four spans.
import { onLCP } from 'web-vitals/attribution';
onLCP(({ value, attribution }) => {
send({
value,
element: attribution.,
: attribution.,
: attribution.,
: attribution.,
: attribution.,
: attribution.,
});
});
Fixing each span
Load delay is where the cheap wins are, because it is usually self-inflicted. The hero must be discoverable by the preload scanner and must be prioritised above everything else fighting for the same connection. Give it fetchpriority="high", since browsers assign low priority to images by default until layout proves they are in the viewport. Never put loading="lazy" on the LCP element: that defers the fetch until layout has run, which guarantees a load delay that no amount of CDN tuning recovers. If the image is a CSS background or is injected by JavaScript, the preload scanner cannot see it in the markup at all, and that is the case where <link rel="preload"> genuinely earns its place — though converting it to a real <img> is usually the better fix.
Load time is a bytes problem: serve modern formats, size the image to the actual layout box rather than shipping a 2400px asset into a 600px slot, and serve it from a CDN. TTFB is a server and edge problem: cache the document, avoid redirect chains, and preconnect to the host serving the hero if it differs from the document origin.
Render delay is where framework choices show up. A synchronous <script> in the head blocks parsing; a stylesheet blocks rendering until it is fetched and parsed, so route-specific CSS should not be in the global blocking bundle. A text LCP in a client-rendered app cannot paint until the bundle downloads, parses, executes and fetches data, which is why the same page server-rendered often has a dramatically better LCP with identical assets.
How CLS and INP ride along
The same work moves the other two vitals, sometimes in the wrong direction. CLS is good at 0.1 or below and poor above 0.25, and image work touches it directly: an image that arrives late without width/height or aspect-ratio reserves no space and shifts everything below it when it lands. Fonts cut both ways — font-display: swap lets text paint early, which helps LCP, but the swap to the real face reshapes the text and can register a shift, whereas font-display: optional protects CLS at the cost of sometimes never using your font.
INP is good at 200ms or below and poor above 500ms, and it is where LCP fixes most often backfire. Deferring a large bundle so it stops blocking the first paint does not delete the work; it relocates it. The bundle now executes slightly later, still on the main thread, quite possibly at the exact moment the user taps the navigation. Because INP is measured across the whole visit rather than during load, you can improve LCP and regress INP with one change. Reducing the total amount of JavaScript, splitting long tasks, and yielding to the main thread between chunks of work help both metrics; merely reordering the script tags helps one and hides the other.
The trap
The trap is reaching for <link rel="preload"> as the reflexive answer. Preload only removes discovery latency, so it does nothing for an <img> that is already sitting in the served HTML where the preload scanner found it before the parser even reached it. Worse, preloads are a zero-sum instruction: every additional high-priority preload competes with the ones that matter and with the render-blocking CSS, so a page that preloads six things has effectively prioritised nothing. A strong answer asks whether the resource is discoverable, and only then decides between fetchpriority, a preload, or removing whatever is starving the connection.
LCP is not one number to optimise, it is four spans in sequence — measure which span dominates in the field before you change anything, because a fix aimed at the wrong span is indistinguishable from no fix at all.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).