Fixing Lazy-Loaded Images That Delay LCP

You enabled lazy loading, initial bytes dropped, and then field data showed LCP regressed from roughly 2.1s to 3.4s on mobile — because the deferral rule caught the hero image. This is the most common self-inflicted LCP regression, and it is the specific recovery path for the broader lazy loading without hurting LCP workflow within the Image & Media Optimization discipline. The symptom is unmistakable: a deferral change that was supposed to be a pure win quietly pushed the Largest Contentful Paint element off the early-discovery path, and now the loading metric is over its 2.5s budget. This page diagnoses why and fixes it in order of impact.

Lazy vs eager hero discovery The lazy hero starts after layout, pushing LCP past 2.5s; the eager hero starts in the first wave and lands under budget. Hero request timing vs LCP budget LCP budget 2.5s Before lazy HTML/CSS hero fetch, late paints 3.4s After eager HTML/CSS hero first wave + fetchpriority=high paints 2.1s Scope eager to the one LCP element; everything below the fold stays lazy.

Rapid Diagnosis: A Five-Point DevTools Checklist

Before changing code, confirm the hero is actually the problem and that deferral is actually the cause. Run this checklist in Chrome DevTools against a throttled mobile profile (Fast 3G, 4x CPU):

  1. Identify the LCP element. Run a Performance trace and read the LCP marker in the Timings track — DevTools names the exact element. Confirm it is the hero image and not a heading or background.
  2. Check the loading attribute. Select that element in the Elements panel. If it shows loading="lazy", you have found the regression in one step.
  3. Check request start time. In the Network panel, filter to images and sort by start time. A correctly eager hero starts in the first wave; a deferred one starts hundreds of milliseconds late, after layout settles.
  4. Check the initiator. Hover the hero request's Initiator column. An eager hero is initiated by the preload scanner / parser; a lazy one is initiated by layout or script much later.
  5. Decompose LCP phases. In the LCP entry, compare load delay and load time. A large load delay with a small render delay points squarely at late discovery — the deferral fingerprint.

If steps 2 through 4 all point at deferral, the rest of this page is your fix. If the loading attribute is already eager but the request still starts late, the problem is discovery priority rather than deferral, and the priority fixes below still apply.

Root Cause Analysis: Four Ways a Hero Gets Deferred

Root cause 1: A blanket lazy-loading default

The most frequent cause is a global rule — a template default, a build transform, or an image component that defaults loading to lazy for every image. The hero is rendered through the same path as every other image, so it inherits the default and gets skipped by the preload scanner. The mechanism is exactly the intended behavior of loading="lazy": the browser deliberately omits lazy images from the early parse-time fetch, so the hero waits until layout proves it is near the viewport — which, for an above-the-fold element, still means it loses the race against CSS, fonts, and the document itself.

Root cause 2: A framework image component defaulting to lazy

Some framework image components default to lazy loading and require an explicit priority or eager prop to opt out. A hero authored with the default component looks correct in source but renders loading="lazy" in the DOM. This is insidious because the component abstracts the attribute away, so a code review of the JSX or template shows no loading attribute at all — you only see the regression by inspecting the rendered HTML.

Root cause 3: IntersectionObserver hiding the URL behind data-src

A custom lazy-loading implementation that stores the real URL in data-src and swaps it into src on intersection makes the hero invisible to the preload scanner entirely — there is no src to discover. Worse, the hero now waits for the observer's JavaScript to parse, execute, and fire before its fetch even begins, stacking script-execution latency on top of the deferral. For the LCP element this is the slowest possible path.

Root cause 4: Eager attribute present but discovery still late

Sometimes the hero is genuinely loading="eager" yet still starts late because it is injected by client-side JavaScript, sits behind a render-blocking stylesheet, or competes with other resources for priority. The attribute is correct, but the element is not discoverable in the initial HTML or not prioritized once discovered. This is a discovery problem rather than a deferral problem, and it is fixed with priority hints and preloading rather than by changing loading.

Step-by-Step Resolution, Ordered by Impact

Fix 1: Make the hero eager

Remove loading="lazy" from the LCP image so the preload scanner discovers and fetches it during the initial HTML parse. Set it explicitly to eager to make the intent obvious and resistant to a future blanket default. This is the single highest-impact change and reverses the core of the regression.

html
<img
  src="hero-1200.jpg"
  srcset="hero-800.jpg 800w, hero-1200.jpg 1200w, hero-1600.jpg 1600w"
  sizes="(max-width: 768px) 100vw, 1100px"
  width="1600" height="900"
  alt="Annual conference main stage"
  loading="eager"
  decoding="async">
<!-- trade-off: eager is correct for the ONE hero only. Do not flip your global
     default back to eager to fix one image, or you re-introduce the offscreen
     over-fetch that lazy loading was solving. Scope eager to the LCP element. -->

Expected outcome: the hero moves back into the first request wave; load delay drops by roughly 500–1500ms, recovering most of the regression on its own.

Fix 2: Add fetchpriority="high" to the hero

Discovery alone gets the request issued early; fetchpriority="high" ensures it is not queued behind less important resources once issued. This is the partner of fix 1 and the detailed mechanics are covered in using fetchpriority to prioritize the LCP image.

html
<img src="hero-1200.jpg" srcset="hero-800.jpg 800w, hero-1600.jpg 1600w"
     sizes="(max-width: 768px) 100vw, 1100px"
     width="1600" height="900" alt="Annual conference main stage"
     loading="eager" fetchpriority="high" decoding="async">
<!-- trade-off: assign fetchpriority=high to exactly one element. Marking several
     images high dilutes the signal and the browser reverts to its heuristics,
     leaving the true LCP image competing for bandwidth again. -->

Expected outcome: shaves a further ~100–300ms off load time on contended connections by jumping the request queue.

Fix 3: Fix the component default, not just the instance

If the cause was a framework component defaulting to lazy (root cause 2), pass the explicit priority prop on the hero and audit every other above-the-fold use of the component. Invert the policy in your shared wrapper so lazy is opt-out for visible media.

jsx
// Next.js example: the hero MUST set priority to escape the lazy default
<Image
  src="/hero-1600.jpg"
  width={1600}
  height={900}
  sizes="(max-width: 768px) 100vw, 1100px"
  alt="Annual conference main stage"
  priority   // sets fetchpriority=high and disables lazy loading for this image
/>
{/* trade-off: priority should be set on at most one or two above-the-fold images.
    Setting it everywhere defeats the purpose and floods the network with eager
    requests, re-creating the bandwidth contention you are trying to remove. */}

Expected outcome: prevents recurrence across the app and fixes any other heroes silently deferred by the same default.

Fix 4: For custom observers, never defer the hero

If a data-src IntersectionObserver swallowed the hero (root cause 3), exclude the LCP element from the observer entirely and render it with a real src. Custom deferral should only ever apply below the fold.

javascript
// Skip the hero: only observe images explicitly marked deferrable
document.querySelectorAll('img[data-src]:not([data-eager])').forEach((img) => io.observe(img));
// trade-off: relying on a class/attribute to exclude the hero is fragile if authors
// forget it. Safer still is to give the hero a normal src and never route it
// through the observer markup at all. -->

Expected outcome: removes script-execution latency from the LCP path, recovering the full deferral penalty plus the observer's own delay.

Fix 5: Preload the hero when discovery is genuinely late

If the hero is eager but injected by script or buried behind blocking CSS (root cause 4), declare it to the preload scanner directly so discovery does not wait for the DOM to settle.

html
<link rel="preload" as="image"
      href="hero-1200.jpg"
      imagesrcset="hero-800.jpg 800w, hero-1600.jpg 1600w"
      imagesizes="(max-width: 768px) 100vw, 1100px"
      fetchpriority="high">
<!-- trade-off: a preload races every other early resource, so preloading a NON-LCP
     image steals bandwidth from the real one. Preload only the confirmed LCP image,
     and remove the hint if the layout changes and it is no longer the LCP element. -->

Expected outcome: surfaces the request at the very start of the load even when the element is added late, eliminating the discovery gap.

Verification: Prove the Recovery

Re-run the exact baseline from the diagnosis checklist and compare. The before/after should show the hero's loading attribute changed from lazy to eager, the request initiator changed from layout/script back to the preload scanner, and the request start time moved into the first wave. Confirm the LCP value itself: in the Performance trace, the LCP marker should sit back under 2.5s on the throttled mobile profile, with load delay collapsed.

diff
- <img src="hero-1200.jpg" loading="lazy" decoding="async">
+ <img src="hero-1200.jpg" loading="eager" fetchpriority="high" decoding="async">

Then lock it in. Add a Lighthouse CI assertion so the hero can never be silently re-deferred, and watch field data — synthetic recovery should show up as a p75 LCP improvement in RUM within a collection window. Confirm the same change did not regress the below-the-fold deferral: initial image bytes should still be low because only the one hero became eager.

json
{
  "ci": {
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "offscreen-images": ["error", { "minScore": 0.9 }]
      }
    }
  }
}

Assert largest-contentful-paint and offscreen-images together so the hero stays eager while everything else stays deferred.

ipt>