Why loading=“lazy” on Above-the-Fold Images Hurts LCP
loading="lazy" is not a small, bounded delay applied to a fetch that would otherwise have happened. It moves the fetch out of the speculative phase of page load — where the preload scanner reads raw HTML bytes before any CSS has been parsed — into the post-layout phase, where the request cannot even be considered until style resolution and the first layout pass have completed. When the element carrying that attribute is the Largest Contentful Paint candidate, the entire cost of the render-blocking critical path is prepended to the image’s own download time. This page quantifies that penalty, shows how to attribute it precisely, and gives a fold-boundary policy that removes it. It assumes you already know the attribute’s mechanics from native lazy loading for images and iframes, the parent guide in Lazy Loading, Preloading & Fetch Priorities.
Prerequisite checklist
What the attribute actually changes in the load sequence
The HTML specification removes a lazily loaded image from the set of resources that are “potentially delaying the load event”, and browsers implement that by declining to start the fetch until the element’s lazy load resumption steps run. Those steps are driven by an internal intersection calculation, and an intersection calculation requires box geometry, which requires layout, which requires the CSSOM. Every render-blocking stylesheet in <head> therefore sits directly between the parser seeing your <img> tag and the browser requesting its bytes.
The eager path avoids all of this. Chromium’s preload scanner tokenises the incoming HTML byte stream on a background thread and dispatches fetches for <img src> tokens without building a DOM node, resolving a style, or knowing where the element will land on the page. That is the whole point of the scanner: it converts network idle time during CSS and font downloads into image download time. A loading="lazy" token makes the scanner skip the element entirely — deliberately, correctly, and disastrously if the element happens to be the hero.
Three properties of that gap make it worse than the raw number suggests. First, it scales with your CSS, not with your image: doubling the size of a render-blocking stylesheet doubles the eager-versus-lazy delta while leaving the eager path’s download time untouched. Second, it is serialised against the connection warm-up — the eager fetch reuses the same TLS connection the HTML arrived on while it is still idle, whereas the lazy fetch arrives after the parser has already opened connections for fonts and scripts and must queue behind them. Third, Chromium assigns a newly resumed lazy image Low priority and only raises it once layout confirms it is in the viewport, so the request briefly competes at the bottom of the queue. The fetchpriority attribute cannot rescue this: priority orders requests that have been queued, and the whole problem is that this one has not been queued yet.
The fold is not a fixed number of pixels
The most common way this defect reaches production is not “someone lazy-loaded the hero”. It is “someone lazy-loaded the second image, which is above the fold on a 27-inch monitor and below it on a phone”. Whether an element is inside the initial viewport depends on the viewport height of the visitor, and the LCP candidate is whichever painted element is largest for that visitor. A gallery thumbnail that never wins on mobile can easily be the LCP element on a desktop where three of them are visible at once.
The asymmetry of the two errors is what should drive the policy. Marking a below-the-fold image eager costs you its transfer bytes on a visit where the user may never scroll — real, but bounded, and it competes at normal priority so it rarely displaces anything critical. Marking an above-the-fold image lazy costs you several hundred milliseconds on the one metric Google grades, on the one element the user is staring at. Bias the boundary downward: when a figure’s position is uncertain, load it eagerly.
Why the defect survives code review
Four independent factors conspire to hide the penalty from the people best placed to catch it, and knowing them is what stops the regression from returning three sprints later.
The development machine has no critical path. Locally, CSS is served from memory over a loopback interface at effectively zero latency, so the CSSOM is ready almost as soon as the parser reaches the <img>. The gap between the eager and lazy paths collapses to single-digit milliseconds. Everything the diagram above attributes to render-blocking CSS simply does not exist on localhost.
A warm cache erases the download half. On the second load, the image comes from disk cache in a few milliseconds, so the load delay is no longer dwarfed by anything and no longer feels like the dominant term. Test with Disable cache ticked and a fresh profile, or you are measuring the wrong page.
The attribute reads as an unambiguous improvement. loading="lazy" is the canonical example of a cheap performance win. A reviewer scanning a diff sees the attribute being added and correctly recognises it as good practice; nothing in the diff communicates that this particular element is the one the metric is measured against. A lint rule that flags the first image in a template is worth more here than any amount of reviewer attention.
Synthetic monitoring often runs with a desktop viewport and a preload. If your monitoring profile happens to include a hero preload, or runs at a viewport where a different element wins LCP, the dashboard stays green while field data degrades. That divergence — lab green, field amber — is the classic signature and the reason step 1 below reads the attribute directly off the element the browser actually chose.
The fix: compute the attribute, do not default it
Replace the global default with a function of document order and cumulative block height. The helper below is framework-agnostic — it runs at render time in Eleventy, Astro, a Django template filter, or a React server component — and it emits the three attributes that must move together.
// media-loading-policy.js
// Decide loading/fetchpriority/decoding for an image from its position in the
// document, instead of applying one blanket default to every <img>.
const TALLEST_FOLD_PX = 1300; // Height of the tallest viewport in your top 90%
// of sessions. Read it from your RUM viewport
// histogram; do NOT copy this number blindly.
const EAGER_MARGIN_PX = 700; // Extra runway below the fold that still loads
// eagerly. Deferring an image the user reaches in
// under a second saves little and risks pop-in.
/**
* @param {number} offsetTop Cumulative height of everything rendered above
* this image, in CSS px, at your widest layout.
* Templates usually know this from the block list.
* @param {boolean} isLcp True for the single element you have measured as
* the LCP candidate on this route.
*/
export function imageLoadingAttrs({ offsetTop, isLcp = false }) {
if (isLcp) {
return {
loading: 'eager', // MUST be eager: fetchpriority is ignored on a
// deferred image, because priority orders queued
// requests and a lazy image is not yet queued.
fetchpriority: 'high', // Exactly one element per document. A second
// "high" flattens the priority ordering instead
// of sharpening it.
decoding: 'sync', // Do not hand the decode to a background task for
// the LCP candidate — the paint is the metric.
sizes: null, // sizes="auto" is only valid with loading="lazy";
// an eager image needs a real sizes expression.
};
}
if (offsetTop < TALLEST_FOLD_PX + EAGER_MARGIN_PX) {
return {
loading: 'eager', // In or near the initial viewport on the tallest
// supported layout. Eager keeps it visible to the
// preload scanner, at normal (not high) priority.
fetchpriority: null, // Omit entirely. "auto" and an absent attribute
// are equivalent; omitting keeps the markup honest.
decoding: 'async',
sizes: null,
};
}
return {
loading: 'lazy', // Genuinely below the fold on every layout.
fetchpriority: null,
decoding: 'async', // Decode off the main thread; the paint is not
// on the critical path once the fetch is deferred.
sizes: 'auto', // Valid ONLY alongside loading="lazy". Always
// follow it with a comma and a fallback list for
// engines that do not implement it yet.
};
}
/** Render helper — omits null attributes rather than emitting empty strings. */
export function imgTag(src, { width, height, alt }, policy) {
const attrs = Object.entries(policy)
.filter(([, v]) => v !== null)
.map(([k, v]) => `${k}="${v}"`)
.join(' ');
return `<img src="${src}" width="${width}" height="${height}" ` +
`alt="${alt}" ${attrs}>`;
}
// Emitted for the hero:
// <img src="/hero.avif" width="1600" height="900" alt="…"
// loading="eager" fetchpriority="high" decoding="sync">
//
// Emitted for a figure 2,400 px down the page:
// <img src="/fig-7.avif" width="1200" height="800" alt="…"
// loading="lazy" decoding="async" sizes="auto">
Tradeoff: EAGER_MARGIN_PX is a bandwidth-for-latency trade with no universally right value. At 700 px on a media-heavy listing page you typically eagerly load one extra row — around 60–120 KB — in exchange for never deferring an element that the browser’s own distance threshold would have fetched moments later anyway. Set it to 0 on data-plan-sensitive routes and check the difference in your image weight budget rather than guessing.
The policy expressed as a decision, for the cases the helper’s two branches compress:
Verification
1. Attribute the delay with the web-vitals attribution build
LCP decomposes into four phases, and this defect lands entirely in one of them. Load the attribution build and read resourceLoadDelay — the interval between time-to-first-byte and the moment the LCP resource’s request actually starts.
// npm i web-vitals@4
// The /attribution entry point is a separate, larger bundle. Ship it behind a
// query flag or to a 1% sample; it is a diagnostic, not a production default.
import { onLCP } from 'web-vitals/attribution';
onLCP(({ value, attribution }) => {
console.table({
'LCP (ms)': Math.round(value),
'TTFB (ms)': Math.round(attribution.timeToFirstByte),
'Load delay (ms)': Math.round(attribution.resourceLoadDelay),
'Load duration (ms)': Math.round(attribution.resourceLoadDuration),
'Render delay (ms)': Math.round(attribution.elementRenderDelay),
'Element': attribution.target, // CSS selector path
'loading attribute': document.querySelector(attribution.target)
?.getAttribute('loading') ?? '(none)',
});
});
A resourceLoadDelay above roughly 200 ms on an element whose loading attribute reads lazy is the signature. It is a load delay problem, not a load duration problem — which is why re-encoding the hero to AVIF, shrinking it, or moving it to a faster CDN produces disappointingly small gains while the attribute stays in place.
2. Run the dedicated Lighthouse audit
Lighthouse ships an audit specifically for this defect. Fail the build on it rather than reading the report by eye.
# The lcp-lazy-loaded audit scores 0 when the LCP element carries loading="lazy".
# --preset=desktop is worth running too: the desktop fold is taller, so an image
# that passes on mobile can still fail here.
npx lighthouse https://example.com/ \
--only-audits=lcp-lazy-loaded,prioritize-lcp-image,largest-contentful-paint \
--output=json --output-path=./lcp-audit.json --quiet
node -e "
const a = require('./lcp-audit.json').audits;
const lazy = a['lcp-lazy-loaded'];
console.log('lcp-lazy-loaded :', lazy.score === 1 ? 'PASS' : 'FAIL');
console.log('prioritize-lcp-image:', a['prioritize-lcp-image'].displayValue || 'no savings');
console.log('LCP :', a['largest-contentful-paint'].displayValue);
process.exit(lazy.score === 1 ? 0 : 1); // non-zero fails the CI step
"
A second, cheaper reading of the same signal is available without any bundle at all. The LCP entry exposes url, loadTime and renderTime; combine it with a PerformanceResourceTiming lookup for the same URL and the load delay is the difference between the resource’s startTime and the navigation’s responseStart. That is enough for a one-off diagnosis on a page you cannot rebuild, and it works from a bookmarklet or a headless script driving the same page under throttling.
3. Assert the policy against the built HTML
The runtime checks above catch the route you tested. This one catches every route, and it is cheap enough to run on every commit alongside the filmstrip diff automation that guards the visual result.
# Flag any page whose FIRST image element in document order is lazy.
# The first <img> is not always the LCP candidate, but a lazy one is always
# worth a human looking at it — in practice this catches every CMS-wide default.
node -e "
const { JSDOM } = require('jsdom');
const fs = require('fs'), path = require('path');
const files = fs.readdirSync('./_site', { recursive: true })
.filter(f => f.endsWith('.html'));
let bad = 0;
for (const f of files) {
const html = fs.readFileSync(path.join('_site', f), 'utf8');
const doc = new JSDOM(html).window.document;
const first = doc.querySelector('main img, article img, body img');
if (first && first.getAttribute('loading') === 'lazy') {
console.error(f, '→ first image is lazy:', first.getAttribute('src'));
bad++;
}
}
process.exit(bad ? 1 : 0);
"
Common mistakes and their corrections
1. A blanket loading="lazy" in the template loop
The single most frequent cause. A component library adds the attribute to every image “for performance”, and the hero inherits it because the hero is rendered through the same component.
<!-- WRONG: the loop cannot know which iteration is above the fold. -->
{% for item in gallery %}
<img src="{{ item.url }}" loading="lazy" width="800" height="600" alt="{{ item.alt }}">
{% endfor %}
<!-- CORRECT: the loop index carries the policy. -->
{% for item in gallery %}
<img src="{{ item.url }}" width="800" height="600" alt="{{ item.alt }}"
loading="{{ 'eager' if loop.index0 < 3 else 'lazy' }}"
{% if loop.first %}fetchpriority="high"{% endif %}>
{% endfor %}
2. Believing fetchpriority="high" cancels loading="lazy"
It does not, and the combination is worse than either attribute alone because it looks correct in review. Priority hints reorder requests that are already in the scheduler’s queue; a deferred image has no queue entry to reorder. The full diagnostic path for hints that appear to be ignored is in debugging fetchpriority conflicts in Chrome DevTools.
3. Putting loading on <source> inside a <picture>
<source> elements only supply candidate descriptors; the <img> is the element that loads. loading on a <source> is an unknown attribute, silently ignored, so an author who “removed the lazy attribute” may have removed it from the wrong element and left the one on <img> intact. Verify against the rendered DOM, not the template, and see art direction with the HTML picture element for the complete placement model.
<!-- WRONG: the attribute is inert here; the img below still defers. -->
<picture>
<source srcset="/hero.avif" type="image/avif" loading="eager">
<img src="/hero.jpg" loading="lazy" width="1600" height="900" alt="Hero">
</picture>
<!-- CORRECT: the img element owns loading, decoding, fetchpriority and sizes. -->
<picture>
<source srcset="/hero.avif" type="image/avif">
<img src="/hero.jpg" loading="eager" fetchpriority="high"
width="1600" height="900" alt="Hero">
</picture>
4. Testing only at scroll offset zero
Restored scroll positions and same-document anchor links both produce a first paint in the middle of the document, where a lazy image is the LCP candidate and the distance threshold has to resolve after layout just as it does at the top. Deep-linked documentation pages and “continue reading” restorations are the routes where this shows up in the field but never in the lab. Watch it in field data through the CrUX API, where a bimodal LCP distribution with a heavy right tail on one URL is the usual fingerprint.
5. Leaving sizes="auto" behind when you switch to eager
sizes="auto" is only defined for elements that also carry loading="lazy". Flip the loading attribute and forget the sizes value and the browser falls back to 100vw, selecting your widest srcset candidate on every viewport — a three- to four-fold payload regression that presents as “LCP got worse after we removed lazy loading”. The helper above returns sizes: null on both eager branches for exactly this reason; for the manual expression that replaces it, see how to calculate optimal sizes attribute values.
<!-- WRONG: auto is invalid without lazy, so this resolves to 100vw. -->
<img srcset="/c-400.avif 400w, /c-1600.avif 1600w"
sizes="auto, 33vw" loading="eager" src="/c-800.avif"
width="1600" height="900" alt="Card">
<!-- CORRECT: an explicit expression that matches the CSS. -->
<img srcset="/c-400.avif 400w, /c-1600.avif 1600w"
sizes="(max-width: 640px) 100vw, 33vw" loading="eager" src="/c-800.avif"
width="1600" height="900" alt="Card">
Related
- Native Lazy Loading for Images and Iframes — the parent guide: distance thresholds, the specified state machine, and CLS pairing rules for the
loadingattribute - Using fetchpriority to Optimize Critical Media — what to apply to the hero once it is eager again, and why only one element per page should get it
- Lazy Loading Iframes for Embedded Video Players — the same deferral question for third-party embeds, where the cost is main-thread time rather than LCP
- Tracking LCP Field Data with the CrUX API — confirm in the field that the load-delay improvement reached real visitors
- Preload vs Prefetch for Video and Image Assets — when an explicit preload is the right tool for a hero the parser discovers late