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-vitals attribution 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.

Which sub-part owns the LCP on each routeThe product route spends 940 ms doing nothing at all before the hero request starts. No amount of image compression touches that segment.Which sub-part owns the LCP on each routeMean composition at the p75 LCP, 28 days/ (home)28%12%43%17%/products/[slug]19%40%30%11%/search24%7%19%50%TTFBLoad delayLoad durationRender delay
The product route spends 940 ms doing nothing at all before the hero request starts. No amount of image compression touches that segment.

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.

The same load before and after the hero becomes discoverableThe 940 ms wait segment disappears entirely once the hero URL is in the server-rendered HTML — the fetch starts at 440 ms instead of 1380 ms.The same load before and after the hero becomes discoverableProduct route, mid-tier Android, 4G0 ms775 ms1,550 ms2,325 ms3,100 msTTFB — 440 msWait: CSS, bundle, hydration — 940 msHero fetch — 1,000 msDecode and paint — 720 msLCP before 3,100 ms
The 940 ms wait segment disappears entirely once the hero URL is in the server-rendered HTML — the fetch starts at 440 ms instead of 1380 ms.

Verifying it works

  1. Field p75 of loadDelay on the affected route should drop toward the 100–200 ms floor. Anything still above 400 ms means a second cause is present.
  2. initiatorType distribution should shift to img or link for the LCP resource. If it is still script, the URL is still being constructed client-side somewhere.
  3. 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.
  4. Check the other sub-parts did not grow. Preloading an image raises its priority at the expense of something else; if loadDuration on 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.

Load delay by how the hero URL is discoveredThe preload scanner can only start what it can see in the HTML byte stream. Everything else waits for a stylesheet or a bundle first.Load delay by how the hero URL is discoveredp75 resource load delay, same image, four markup patternsIn server-rendered markup90 msPreloaded from the head140 msCSS background image610 msBuilt by client JavaScript940 ms0 ms250 ms500 ms750 ms1,000 ms
The preload scanner can only start what it can see in the HTML byte stream. Everything else waits for a stylesheet or a bundle first.

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 srcset mismatch 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 redirectCount on 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, resourceLoadDelay is 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.