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.
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.
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.elementfrom the lastlargest-contentful-paintentry. 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. Iflazyis 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
srcplaceholder swap on that element. If the raw HTML ships a 1 px data URI insrcwith the real URL indata-src,eagerbuys you nothing: the request is still script-created and still late. The LCP image must carry its real URL insrcin the server response. - [ ] 6. Confirm the element is not inside a
display: noneorcontent-visibility: hiddensubtree. 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 hasloading === '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.
| 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
- Lazy Loading & Viewport-Driven Fetching — parent topic: thresholds per engine, the support matrix, and the full rollout
- Choosing loading=“lazy” vs IntersectionObserver — which mechanism owns each element, and migrating off a
data-srcswap - Up: Core Browser Loading Mechanics & Priority Queues