Diagnosing LCP Resource Load Delay
Resource load delay is the quietest of the four LCP sub-parts and frequently the largest. It is the time between the document’s first byte arriving and the browser starting the request for the LCP resource — pure waiting, with no bytes moving and nothing rendering. This guide sits under LCP Measurement & Optimization and covers how to measure the delay in the field, the five causes that produce nearly all of it, and how to confirm you actually removed it rather than moving it somewhere else.
The reason it goes unnoticed: every waterfall screenshot in a bug report shows the hero image download as a long bar, so teams optimise the file. The bar before it — the one where nothing is happening — is usually the bigger number.
Prerequisites
- The
web-vitalsattribution build wired into your reporter, as set out in Debugging Web Vitals with the Attribution Build. - Field beacons that carry the four LCP sub-parts separately, not just the total.
- Access to the document HTML as it is actually served, including anything a tag manager or edge worker injects.
- A route where LCP is failing, identified from field data rather than a lab run.
How to find and remove the delay
Step 1 — Capture the four sub-parts separately
import { onLCP } from 'web-vitals/attribution';
onLCP((metric) => {
const a = metric.attribution;
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'LCP',
value: Math.round(metric.value),
// The four parts sum to the metric. Store them individually or you
// cannot tell a slow file from a late request.
ttfb: Math.round(a.timeToFirstByte),
loadDelay: Math.round(a.resourceLoadDelay),
loadDuration: Math.round(a.resourceLoadDuration),
renderDelay: Math.round(a.elementRenderDelay),
element: a.element,
url: a.url,
route: window.__route,
}));
});
Aggregate the p75 of each part independently. They will not sum to the p75 of the total — percentiles do not add — but the relative sizes tell you which subsystem owns the problem.
Step 2 — Identify which of the five causes applies
Load delay comes from a small, well-defined set of causes. Work down the list in order; the first match is usually the whole answer.
| Cause | Signature in the data | Fix |
|---|---|---|
| Hero not in the initial HTML | Delay ≈ time to hydrate or fetch data | Server-render the hero markup |
Hero is a CSS background-image |
Delay ≈ stylesheet download + parse | Move to <img>, or preload the URL |
| Render-blocking resources ahead of it | Delay tracks stylesheet and script count | Inline critical CSS, defer the rest |
| Lazy-loading applied to the hero | Delay ≈ time to first layout | Remove loading="lazy" above the fold |
| Client-side image URL construction | Delay ≈ bundle parse and execute | Emit the URL server-side, or preload it |
The preload scanner is what ties these together. It reads ahead in the raw HTML byte stream and starts fetches before the parser gets there — but only for resources whose URLs are literally present in that HTML. Anything constructed by JavaScript, referenced from a stylesheet, or inserted after hydration is invisible to it.
Step 3 — Prove the cause in a single run
// Paste into the console on a cold load. Shows when the hero request
// actually started relative to the document response.
const nav = performance.getEntriesByType('navigation')[0];
const lcpEntry = performance.getEntriesByType('largest-contentful-paint').at(-1);
const heroUrl = lcpEntry?.url;
const res = performance.getEntriesByType('resource').find((r) => r.name === heroUrl);
console.table({
responseEnd: Math.round(nav.responseEnd),
heroRequestStart: res ? Math.round(res.startTime) : 'not a resource (text LCP)',
loadDelay: res ? Math.round(res.startTime - nav.responseEnd) : 0,
heroDownload: res ? Math.round(res.duration) : 0,
lcp: Math.round(lcpEntry.startTime),
initiator: res?.initiatorType,
});
initiatorType is the tell. img means the parser found it in the markup — good. css means it came from a stylesheet, so the browser had to download and parse CSS first. script or xmlhttprequest means JavaScript created it, which is the worst case and usually the largest delay.
Step 4 — Make the URL discoverable
The fix is always the same shape: get the URL into the initial HTML in a form the preload scanner can see.
<!-- Best: the hero is a real <img> in the server-rendered markup. -->
<img src="/hero-800.webp"
srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="(max-width: 640px) 100vw, 640px"
width="1200" height="675" alt="" fetchpriority="high">
<!-- Second best, when the hero must stay a background image: preload it
so the scanner starts the fetch without waiting for the stylesheet. -->
<link rel="preload" as="image" href="/hero-800.webp"
imagesrcset="/hero-400.webp 400w, /hero-800.webp 800w"
imagesizes="(max-width: 640px) 100vw, 640px"
fetchpriority="high">
Preload is the workaround, not the goal — it duplicates the URL in two places and will silently fetch the wrong file the day someone changes the CSS. Where you can move the hero into markup, do that instead; the reasoning is in Optimizing LCP with fetchpriority & preload.
Step 5 — Clear the path in front of it
Even a perfectly discoverable hero waits behind render-blocking resources on a constrained connection. Two changes matter more than the rest: inline the critical CSS the hero needs and defer everything else, and make sure no synchronous script sits above the hero in the document. The detail is in Reducing Render-Blocking CSS for FCP.
Verifying it works
- Field p75 of
loadDelayon the affected route should drop toward the 100–200 ms floor. Anything still above 400 ms means a second cause is present. initiatorTypedistribution should shift toimgorlinkfor the LCP resource. If it is stillscript, the URL is still being constructed client-side somewhere.- Total LCP should move by roughly the delay you removed. If it does not, the hero was not on the critical path — check whether the LCP element itself changed, which is common on responsive layouts.
- Check the other sub-parts did not grow. Preloading an image raises its priority at the expense of something else; if
loadDurationon another resource grew by the same amount, you moved the problem rather than solving it.
When the delay is on the server side of the CDN
One variant deserves separate treatment because the fix lives outside the browser entirely. If the hero is served from an image transformation service that generates variants on demand, the first request for an uncached size does the transformation synchronously. That work appears in the browser as download time rather than load delay, but the shape in the field data is distinctive: a bimodal distribution where warm requests take 90 ms and cold ones take 800 ms, with almost nothing in between.
The diagnosis is to record the CDN cache status header on the beacon alongside the LCP resource URL, the same way the TTFB analysis in Segmenting TTFB by CDN Cache Status does it. If the slow mode correlates with a cache miss, the answer is to pre-generate the handful of variants your srcset actually references at deploy time rather than letting the first real user pay for each one.
Edge cases and gotchas
- The LCP element changes between viewports. A text block on mobile and an image on desktop are different metrics with different sub-part profiles. Segment by viewport before comparing.
- Preloading a resource the page does not use. A
srcsetmismatch between the preload and the<img>downloads two files. Chromium warns in the console; nobody reads it in production. - Third-party image CDNs with redirect chains. A 302 adds a full round trip to load delay and does not appear as download time. Check
redirectCounton the resource entry. - Service worker interception. A worker that fetches, inspects and re-serves the hero adds its own startup cost to load delay on the first navigation after activation.
- Early Hints and preconnect help, but not with discovery. They remove connection setup, not the wait for the URL to be known. Both are worth having; neither substitutes for markup.
- Text LCP has no resource. When the largest element is text,
resourceLoadDelayis zero by definition and the whole budget lives in TTFB and render delay. Do not chase a number that cannot exist.
FAQ
What is a good value for resource load delay?
Under about 100 ms on a well-built page, and under 200 ms is fine. Above 500 ms there is a structural cause from the table above, not a tuning opportunity.
Why does preload not fix my delay?
Usually because the preload is below other render-blocking resources, points at a different candidate than the <img> selects, or the hero is being inserted after hydration so the preloaded file sits unused in the cache until then.
Does load delay count against me if the image is cached?
A warm cache collapses both load delay and load duration, which is exactly why field data from real repeat visitors reads faster than any cold lab run. Segment by cache state if you want to see the first-visit experience clearly.
The delay is small but LCP is still poor — what now?
Look at which sub-part is largest. A large render delay points at main-thread work between the response arriving and the paint, which is usually hydration — see Measuring React Hydration’s Impact on LCP.
Related
- LCP Measurement & Optimization — the metric, its thresholds and the full capture pattern.
- Optimizing LCP with fetchpriority & preload — the priority side of the same problem.
- Self-Hosted Beacon Collection — getting the sub-part fields into storage where you can aggregate them.