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.

Discovery path for the same hero image, eager versus lazy Two horizontal tracks. The eager track has three stages that all happen before layout: the preload scanner reads the img src from raw bytes, the request is queued at high priority before the CSSOM exists, and bytes are on the wire while CSS is still parsing, giving a fetch start around forty milliseconds. The lazy track has five stages: the scanner skips the lazy token, the parser builds the DOM node, the CSSOM finishes after render-blocking CSS, the first layout establishes geometry, and only then does the distance check queue the fetch, giving a fetch start around six hundred and ten milliseconds. Same <img>, same URL, two discovery paths loading="eager" — every step runs before layout Preload scanner reads src from raw bytes Request queued before CSSOM exists Bytes on the wire while CSS still parses fetch starts ≈ 40 ms loading="lazy" — three of five steps wait on render-blocking CSS Scanner skips the lazy token Parser builds the DOM node CSSOM ready blocking CSS First layout geometry known Distance check queues fetch fetch starts ≈ 610 ms Δ ≈ 570 ms of the LCP budget spent before one byte of the hero image is requested. Ordering diagram, not a linear time axis. Timings from a Moto G Power on a Slow 4G profile with 62 KB of render-blocking CSS. The amber stages are the ones that exist only because the scanner was told to skip the element.

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.

Where the fold lands on three common viewport heights A vertical page column drawn to scale from zero to seventeen hundred CSS pixels. The hero occupies zero to six hundred and twenty pixels, figure two occupies seven hundred to eleven hundred and eighty, and figure three occupies twelve hundred and fifty to sixteen hundred and fifty. Three fold lines are marked: six hundred and forty pixels for a small phone, where only the hero is visible; seven hundred and eighty pixels for a laptop, where the hero and figure two are visible; and thirteen hundred pixels for a desktop, where all three figures are visible. One document, three folds — drawn to scale in CSS pixels Document offset increases downward; 0 px at the top of the column. Hero — 0 to 620 px eager everywhere Figure 2 — 700 px eager on laptop and up Figure 3 — 1250 px safe to lazy-load 640 px fold — 360 × 640 phone inside the viewport: hero only 780 px fold — 1440 × 900 laptop inside the viewport: hero + figure 2 1300 px fold — 27-inch desktop inside the viewport: all three figures Choose the eager/lazy boundary against the tallest viewport you actually serve, never against your development window.

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:

Choosing the loading attribute for one image A three-question decision tree. If the element is the Largest Contentful Paint candidate at any supported viewport, use loading eager plus fetchpriority high. Otherwise, if its top edge sits above the fold on the tallest supported viewport, use loading eager with no priority hint. Otherwise, if it is within about one viewport height of the fold, still use loading eager because deferral saves little and risks visible pop-in. Only if all three answers are no does the image get loading lazy with explicit dimensions and asynchronous decoding. Is this the LCP candidate at any viewport you support? yes loading="eager" + fetchpriority="high" (one per page) no Is its top edge above the fold on your tallest supported viewport? yes loading="eager", no priority hint scanner-visible at normal priority no Is it within roughly one viewport height below that fold? yes loading="eager" anyway deferral saves little, risks pop-in no loading="lazy" + width/height + decoding="async" + sizes="auto" Only this leaf may carry the lazy attribute. Everything above it is scanner-visible by design.

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.

LCP phase decomposition — eager versus lazy hero Two stacked bars measured on a Slow 4G profile. The eager hero totals two thousand and thirty milliseconds: six hundred and forty of time to first byte, one hundred and twenty of resource load delay, one thousand one hundred and eighty of load duration and ninety of render delay. The lazy hero totals two thousand six hundred milliseconds with the identical phases except a resource load delay of six hundred and ninety milliseconds, pushing it past the two and a half second threshold. The whole regression lands in one phase Same image, same bytes, same CDN. Only the loading attribute differs. eager 2,030 ms lazy 2,600 ms 2.5 s threshold TTFB — 640 ms Resource load delay Load duration — 1,180 ms Render delay — 90 ms Load delay grows from 120 ms to 690 ms. Every other phase is byte-identical, so no amount of re-encoding closes the gap.

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">