Fixing Lazy-Loaded LCP Image Regressions

Your Largest Contentful Paint went from 0.94 s to 2.31 s in the same deploy that added loading="lazy" to every <img> in the template — including the hero that is the LCP element.

Root cause: the request cannot exist until layout does

The HTML specification defines lazy loading through one internal object, the lazy load intersection observer. When the parser builds an <img> whose loading attribute is in the Lazy state, it does not start the fetch. It registers the element with that observer and parks the lazy load resumption steps. Only when the observer reports that the element has entered its implementation-defined margin do those steps run and hand the element to the ordinary image fetch algorithm.

Intersection is a geometric question, so nothing can be decided before the element has a box. That single fact is the whole regression. The preload scanner — the tokenizer-ahead pass that opens connections while the main parser is still blocked — reads the src, sees the Lazy state, and deliberately declines to speculate. The request therefore waits for render-blocking CSS to arrive and be applied, for the box tree to be built, for first layout to position the element, and then for the next “update the rendering” turn in which observer callbacks are actually delivered. On a mid-tier phone with a 40 KB stylesheet on Fast 4G, that chain is 350 to 600 ms of wall clock during which the connection is idle as far as your hero is concerned.

There is a second penalty stacked on the first, and teams routinely miss it because the first one is so visible. Chromium creates image requests in the Low band and promotes those found inside the viewport at first layout. A lazy hero is resumed after that layout pass, so it enters the queue while app.js and the first gallery tiles are already occupying the connection. It is not merely 400 ms late; it is 400 ms late and then sharing bandwidth it would otherwise have had to itself. The mechanics of that promotion are covered in how browser fetch priority affects LCP; here it is enough to know that deferral costs you both the start time and the band.

One img element, two scheduler paths Two horizontal chains of four states each. The eager chain: the parser reaches the image at 232 milliseconds, the preload scanner requests it with no layout needed, bytes are in flight from 232 to 922 milliseconds, and the hero paints at 0.94 seconds. The lazy chain: the parser reaches the image but the scanner skips it, the element is deferred with no fetch from 232 to 610 milliseconds, first layout resumes it and the request is created at 640 milliseconds, and the hero paints at 2.31 seconds. One img element, two scheduler paths — same 210 KB hero, same connection loading=eager — the preload scanner owns the request parser reaches img 232 ms scanner requests it no layout needed bytes in flight 232 - 922 ms hero painted LCP 0.94 s loading=lazy — the lazy load intersection observer owns the request parser reaches img scanner skips it deferred, no fetch 232 - 610 ms layout resumes it request at 640 ms hero painted LCP 2.31 s 408 ms of pure discovery delay before a single byte is requested. Then another 960 ms, because the request joins a connection the stylesheet and the bundle already own.

Read the two chains as the same element under two rules. Nothing about the image, the server, the connection or the encoding differs between them. The only variable is the moment the browser was permitted to ask for the bytes, and that moment is decided by whether the scanner or the observer owns the request.

Minimal reproduction

The regression almost never looks like a mistake in review. It looks like consistency: an image partial that stamps the same attributes onto every image, applied to a page whose first image happens to be the hero.

<!-- BROKEN. The scheduling failure is on line 3, not in the markup shape.
     loading="lazy" removes this element from the preload scanner's reach, so
     the request is created after first layout; fetchpriority="high" then
     applies to a request that does not exist yet and changes nothing. -->
<img src="/img/hero-1280.avif"
     srcset="/img/hero-640.avif 640w, /img/hero-1280.avif 1280w"
     sizes="(max-width: 760px) 100vw, 1280px"
     width="1280" height="720" alt="Warehouse floor with picking robots"
     loading="lazy" fetchpriority="high" decoding="async">
<!-- FIXED. eager is the default, so the attribute is redundant to the browser
     and load-bearing for the next engineer editing this partial. It keeps the
     URL visible to the preload scanner, which opens the connection while the
     main parser is still blocked on the stylesheet; fetchpriority="high" then
     places that early request in the High band instead of Low, so it is not
     outbid by app.js during the same round trip. -->
<img src="/img/hero-1280.avif"
     srcset="/img/hero-640.avif 640w, /img/hero-1280.avif 1280w"
     sizes="(max-width: 760px) 100vw, 1280px"
     width="1280" height="720" alt="Warehouse floor with picking robots"
     loading="eager" fetchpriority="high" decoding="async">

The generalisation matters more than the one element. Eagerness is a property of an element’s position in the document, not of its index in a loop, so it belongs in the template expression rather than in a hand-edited exception.

{# Scheduling rationale: at 360 px the grid is one column, at 1440 px it is
   three, so the union of above-the-fold tiles across breakpoints is the first
   three — not "the first one". Deciding this server-side keeps every eager URL
   in the initial HTML where the preload scanner can still act on it; deciding
   it client-side would push all three back behind hydration. #}
{% for item in products %}
  <img src="{{ item.img }}" width="400" height="400" alt="{{ item.alt }}"
       loading="{{ 'eager' if loop.index0 < 3 else 'lazy' }}"
       fetchpriority="{{ 'high' if loop.index0 == 0 else 'auto' }}"
       decoding="async">
{% endfor %}

Proving it in the Network panel

Reload cold with Fast 4G throttling, the Priority column enabled and Big request rows on, and do not scroll. The signature is unmistakable once you know it: the hero is not the first request, its Start value sits several hundred milliseconds after the stylesheet finished, and its priority reads Low at creation rather than High.

The lazy hero as it appears in the Network panel A stylised network request table with five rows: document.html at Highest priority starting at 0 milliseconds, app.css at Highest starting at 240 milliseconds, app.js at High starting at 260 milliseconds, hero.avif marked as the LCP element at Low priority starting at 640 milliseconds and running to 2.29 seconds, and tile-01.avif at Low starting at 700 milliseconds. The hero row is outlined and the LCP marker sits at 2.31 seconds. Network panel, Fast 4G, cold cache, no scrolling LCP 2.31 s Name Priority Start Waterfall document.html Highest 0 ms app.css Highest 240 ms app.js High 260 ms hero.avif — LCP Low 640 ms tile-01.avif Low 700 ms 0 1 s 2 s 3 s 640 ms is not congestion — the request did not exist until first layout at 610 ms. app.css finished at 500 ms; the hero then entered the queue at Low, behind app.js and the first tile.

Two readings of that panel are worth internalising. First, a Low priority on an image is normal at creation — it is the combination of Low and a 640 ms start that identifies a resumed lazy element rather than a demoted eager one. Second, the gap between app.css finishing at 500 ms and the hero starting at 640 ms is the observer round trip: layout, then the next rendering turn, then the fetch. Interpreting the rest of the timing bars is covered in decoding the DevTools network waterfall.

When you need the answer in one shot rather than by eye, rank the candidates directly. Scroll to the top of the page first, then paste this into the console:

// Diagnostic rationale: the LCP candidate is whichever painted element covers
// the most *initial viewport* area, which is rarely the first image in source
// order — a two-column grid promotes a different tile at every breakpoint.
// Ranking by intersected area reproduces the browser's own choice, so the top
// row is the element whose loading attribute actually decides your metric.
const vw = innerWidth, vh = innerHeight;
console.table(
  [...document.querySelectorAll('img, video, svg, [style*="background-image"]')]
    .map((el) => {
      const r = el.getBoundingClientRect();
      const w = Math.max(0, Math.min(r.right, vw) - Math.max(r.left, 0));
      const h = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0));
      return {
        area: Math.round(w * h),
        lazy: el.loading === 'lazy',        // the regression, in one column
        priority: el.fetchPriority || 'auto',
        src: (el.currentSrc || el.src || '').split('/').pop()
      };
    })
    .filter((c) => c.area > 0)
    .sort((a, b) => b.area - a.area)
    .slice(0, 5)
);

Any row in the top one or two with lazy: true is the bug. Run it at each breakpoint you support — the ranking changes, and so does the answer.

Deterministic fix protocol

Work top to bottom. Each step either clears a cause or confirms it, and none of them requires the previous one to have succeeded.

  • [ ] 1. Identify the real LCP element, not the presumed one. Run the ranking snippet above at 360 px, 768 px and 1440 px, or read entry.element from the last largest-contentful-paint entry. Do not skip this: on responsive grids the LCP element differs by breakpoint, and fixing the wrong image changes nothing.
  • [ ] 2. Check the delivered HTML, not the Elements panel. Use view-source on the production URL and search for loading. If lazy is absent in the source but present in the DOM, a script or image plugin is adding it after parsing — which also means the scanner already saw the eager markup and your remaining problem is elsewhere.
  • [ ] 3. Set loading="eager" explicitly on every element in the eager set. The union across breakpoints, not the first element. Write the attribute rather than relying on the default, so the next blanket edit to the image partial has to overwrite something visible.
  • [ ] 4. Add fetchpriority="high" to exactly one element per page. The single largest above-the-fold image. Adding it to three defeats the purpose — a band with everything in it is the band with nothing in it. If the hint appears to do nothing, work through fetchpriority=high not working on your LCP image.
  • [ ] 5. Remove any src placeholder swap on that element. If the raw HTML ships a 1 px data URI in src with the real URL in data-src, eager buys you nothing: the request is still script-created and still late. The LCP image must carry its real URL in src in the server response.
  • [ ] 6. Confirm the element is not inside a display: none or content-visibility: hidden subtree. A hero inside a closed accordion, a hidden tab panel or an off-screen carousel slide never intersects, so a lazy attribute there means the image is not requested at all until the reveal — which reads as an infinite regression rather than a slow one.
  • [ ] 7. Re-measure the LCP sub-parts, not just the total. Resource load delay should fall under 100 ms; if it does not, discovery is still broken. If load delay is fine but load duration is long, you have a contention problem and the deferral was never the whole story.
  • [ ] 8. Install a guard before you close the ticket. Assert in the build that the template’s hero partial does not emit loading="lazy", and report from the field whenever the LCP entry’s element has loading === 'lazy'. Every team that fixes this once fixes it again within two quarters unless something fails loudly.

Before and after

Measured on a product listing template — 210 KB AVIF hero, 38 gallery tiles, Fast 4G emulation, cold cache, median of nine runs. “Before” is the blanket-lazy template; “after” is steps 3 through 5 applied and nothing else changed.

LCP broken into its four sub-parts, before and after Two stacked horizontal bars on a shared axis from 0 to 2.4 seconds. Before: 210 milliseconds of time to first byte, 430 milliseconds of resource load delay, 1650 milliseconds of resource load duration and 20 milliseconds of element render delay, totalling 2310 milliseconds. After: the same 210 millisecond time to first byte, 24 milliseconds of load delay, 690 milliseconds of load duration and 18 milliseconds of render delay, totalling 940 milliseconds. LCP broken into its four sub-parts, before and after the fix Same page, same throttling, same 210 KB AVIF hero — only the loading attribute changed. Before — hero lazy 210 430 1,650 ms LCP 2,310 ms After — hero eager 210 690 ms LCP 940 ms 0 0.5 s 1.0 s 1.5 s 2.0 s 2.4 s TTFB load delay load duration render delay The regression lives in the middle two segments: the request started 406 ms late and then took 960 ms longer.
Metric Hero lazy Hero eager + high Delta
LCP (lab, p50, Fast 4G) 2,310 ms 940 ms −1,370 ms
TTFB 210 ms 210 ms unchanged
Resource load delay 430 ms 24 ms −406 ms
Resource load duration 1,650 ms 690 ms −960 ms
Element render delay 20 ms 18 ms −2 ms
Hero request start 640 ms 232 ms −408 ms
Hero priority at creation Low High promoted at discovery
LCP (field, p75, mobile) 3.6 s 2.0 s −44%
Lighthouse performance 62 91 +29

The TTFB row is the control: the server did the same work in both runs, so nothing in this table is a backend improvement. Note also that the render-delay column barely moves — decode and paint were never the problem, which is why decoding="async" and preloading a font make no difference to this failure class. The entire recovery is the two middle rows, and they map exactly onto the two penalties from the root-cause section: discovery, then contention. If you want to reason about the contention half in isolation, that is the subject of the priority queue model.

FAQ

I removed loading=lazy from the hero and LCP barely moved. What now?

Removing the attribute fixes discovery, not contention. Split the metric into its sub-parts: if resource load delay is now under 100 ms but resource load duration is still long, the request exists early and is simply losing bandwidth to the stylesheet and the application bundle. That is a priority problem — add fetchpriority="high", and check that no <link rel="preload"> for the same URL is firing at the default Low image priority and creating a second request row.

The hero is only the LCP element on desktop. Should it be eager everywhere?

Yes. The two mistakes are not symmetrical. An eager image that turns out to sit below the fold costs one early request the browser would have made a few hundred milliseconds later anyway. A lazy image that turns out to be the LCP element costs roughly 400 ms of discovery delay plus whatever contention it then loses — in the table above, 1,370 ms. Take the union of the above-the-fold sets across your breakpoints and make all of them eager.

Chrome reports a different LCP element on every run. Which one do I trust?

All of them, in order: LCP is revised as larger elements paint, and only the final entry counts. A lazily loaded hero produces exactly this signature — a heading wins the metric at 0.8 s, then the image lands at 2.3 s and replaces it. Read the last entry from the observer, and treat a run where the element flips from text to image late as evidence of the regression rather than as measurement noise.


Related