Native Lazy Loading for Images and Iframes

Native lazy loading — implemented via the loading attribute on <img> and <iframe> elements — is the zero-JavaScript mechanism for deferring offscreen resource fetches until the element approaches the viewport. It is one of the most impactful techniques covered in Lazy Loading, Preloading & Fetch Priorities, reducing initial page payload by 15–40% on media-heavy routes while freeing main-thread budget during the critical rendering path. Getting the implementation right means understanding browser-specific fetch thresholds, how to pair loading="lazy" with explicit dimensions to prevent cumulative layout shift (CLS), and when to fall back to JavaScript for elements the browser cannot natively defer.

Concept & Architecture: How the Browser Decides When to Fetch

The loading attribute instructs the browser’s resource scheduler — not the HTML parser — to defer a request. When the parser encounters loading="lazy" on an <img>, it records the element’s geometry and passes it to an internal distance-from-viewport calculator. The fetch is suppressed until the element’s nearest scrolling ancestor brings it within a configurable threshold distance.

The diagram below illustrates the lifecycle from HTML parse through deferred fetch.

Native lazy loading lifecycle A flow diagram showing five stages: HTML parse detects loading=lazy, browser registers element geometry, scroll event triggers threshold check, fetch fires when within distance threshold, image decodes and paints. HTML parser detects loading=lazy Geometry registered Scroll / resize triggers threshold check Fetch fires (within threshold) Decode & paint Parse phase Layout phase Runtime Network Rendering Chromium: ~1250 px on fast / ~500 px on slow connections

Fetch Distance Thresholds by Browser and Connection Type

The threshold — measured as vertical distance in CSS pixels between the element’s top edge and the viewport bottom — is not standardised in the HTML specification. Each engine sets its own values and adjusts them per network quality:

Browser Fast connection (4G / WiFi) Slow connection (3G / Save-Data)
Chromium 85+ ~1,250 px ~500 px
Firefox 93+ ~800 px ~300 px
Safari 15.4+ Not published (conservative) Not published
Edge 18 (EdgeHTML) No native support No native support
Edge 79+ (Chromium) Same as Chromium Same as Chromium

Warning: Threshold values are implementation details subject to change without notice. Do not architect layout based on an assumed threshold — test with WebPageTest under throttled profiles instead.

The browser infers connection quality from the Network Information API and from TCP round-trip measurements. A user switching from WiFi to a mobile network mid-scroll will cause the threshold to shrink, potentially delaying fetches that would have fired sooner on the faster connection.

Chromium additionally halves the effective threshold when the request carries Save-Data: on, which is why a device with Data Saver enabled behaves like a permanently throttled connection even on WiFi. That is deliberate: the threshold is a bandwidth-versus-latency trade, and Data Saver declares that the user has chosen bandwidth. If your CDN varies responses on Save-Data, remember that the reduced threshold applies to every lazy image on the page, so a long article with many below-the-fold figures will fire far more individual fetch bursts than the same page on an unthrottled connection.

Distance-from-viewport fetch thresholds, drawn to scale A page column with the viewport at the top and the fold marked beneath it. Four horizontal markers are drawn to scale below the fold: Firefox on a slow connection at about 300 CSS pixels, Chromium on a slow connection at about 500 pixels, Firefox on a fast connection at about 800 pixels, and Chromium on a fast connection at about 1250 pixels. Safari does not publish its threshold. How far below the fold the fetch actually starts Viewport eager fetches only fold pre-fetch zone Firefox 93+, slow (3G / Save-Data) — ~300 px Chromium 85+, slow (3G / Save-Data) — ~500 px Firefox 93+, fast (4G / WiFi) — ~800 px Chromium 85+, fast (4G / WiFi) — ~1,250 px Safari 15.4+ does not publish a figure; Edge 79+ matches Chromium. Drawn to scale in CSS pixels.

What the HTML Specification Actually Mandates

The threshold numbers are engine policy, but the surrounding state machine is specified. loading is an enumerated attribute on HTMLImageElement and HTMLIFrameElement with two keywords, lazy and eager. Its invalid value default is eager, so a typo such as loading="lazyload" silently produces eager loading rather than an error — a failure mode worth an assertion in your build pipeline. The attribute is reflected as the IDL property img.loading, and reading it back returns the canonicalised keyword, never the raw string you authored.

Three specified behaviours have direct architectural consequences:

  1. Lazy images do not delay the load event. The spec explicitly removes a lazily loaded image from the set of resources that are “potentially delaying the load event”. window.onload therefore fires while below-the-fold images are still unfetched. Any analytics beacon, hydration trigger, or screenshot tool keyed to load will observe a page whose lazy images are blank — a common source of false regressions in visual-diff suites.
  2. Flipping loading at runtime resumes the load immediately. Setting img.loading = 'eager' on an element that was parsed as lazy runs the spec’s lazy load resumption steps, starting the fetch on the spot. This is the cleanest way to force-load a specific image (for example, the target of an in-page anchor jump) without touching src and without re-running source selection.
  3. Lazy loading is disabled when scripting is disabled. The spec requires user agents to ignore the lazy state in documents where scripting is off, because a scroll-gated fetch is otherwise a scriptless mechanism for measuring how far a user scrolled. This is why a <noscript>-only test harness cannot be used to verify deferral behaviour.

Inside a <picture> element the attribute belongs on the <img>, never on a <source>. <source> carries only the candidate descriptors; the <img> is the element that actually loads, so loading="lazy" on a <source> is an unknown attribute and is ignored without warning. The same rule governs decoding, fetchpriority, width, and height — see art direction with the HTML picture element for the full attribute placement model.

Interaction With the Preload Scanner

Browsers run a speculative preload scanner over the raw HTML byte stream ahead of the main parser, queueing fetches for <img src>, <link>, and <script src> before the DOM for those nodes exists. The preload scanner deliberately skips images whose token carries loading="lazy", which is exactly the intended behaviour — but it also means a lazy image gains nothing from being early in the document, and that any technique which hides the attribute from the scanner defeats it. Two patterns break the scanner’s ability to see the attribute:

  • Images written by document.write() or injected by a framework after parse never pass through the scanner at all. They still honour loading="lazy", but they lose the scanner’s head start when marked eager.
  • Attribute order does not matter to the scanner, but a src supplied through a templating expression that the scanner cannot resolve (for instance src="{{ url }}" left unrendered in a client-side template) yields a bogus speculative fetch for the literal string. Server-render your src values.

Warning: Because the preload scanner ignores lazy images entirely, loading="lazy" and a <link rel="preload" as="image"> for the same URL produce a guaranteed conflict — the preload wins and the fetch happens at full priority during the critical path.

Browser & API Compatibility Matrix

Feature Chrome 85+ Firefox 93+ Safari 14 Safari 15.4+ Safari 16+ Edge 79+
loading="lazy" on <img> Yes Yes No Yes Yes Yes
loading="lazy" on <iframe> Yes Yes No No Yes Yes
loading="eager" Yes Yes Yes Yes Yes Yes
decoding="async" Yes Yes Yes Yes Yes Yes
fetchpriority on <img> Yes (102+) Yes (132+) No No Yes (17.2+) Yes (102+)
HTMLImageElement.prototype.loading (feature detect) Yes Yes No Yes Yes Yes
sizes="auto" on lazy images Yes (133+) Yes (139+) No No No Yes (133+)
contain-intrinsic-size Yes (83+) Yes (107+) No No Yes (17+) Yes (83+)
content-visibility: auto Yes (85+) Yes (125+) No No Yes (18+) Yes (85+)
Threshold reduced under Save-Data: on Yes No No No No Yes

Warning: Safari 14 has no native lazy loading at all. Safari 15.4 adds loading="lazy" for images but not iframes. Plan JS fallback for both.

Feature-detect with 'loading' in HTMLImageElement.prototype rather than sniffing the user agent. The property is only present when the engine implements the attribute, so the check is exact; the equivalent iframe test is 'loading' in HTMLIFrameElement.prototype, and the two can disagree — Safari 15.4 answers true for images and false for iframes.

Step-by-Step Implementation

Step 1 — Images: Add loading, decoding, and Explicit Dimensions

Explicit width and height attributes are not optional when lazy loading. Without them the browser cannot calculate the element’s aspect ratio before the image loads, producing a layout shift (CLS) as the image snaps into its reserved space.

<!-- Production-ready lazy image -->
<img
  src="/assets/product-shot.webp"
  width="800"           <!-- reserves layout space, preventing CLS -->
  height="600"          <!-- pair with width to lock aspect ratio -->
  alt="Product photo: titanium laptop stand, angled view"
  loading="lazy"        <!-- defer fetch until within threshold distance -->
  decoding="async"      <!-- allow parallel decode off the main thread -->
/>

For responsive images using srcset, the same attributes apply. The browser selects the correct source candidate before fetching, so lazy loading and responsive selection are complementary, not conflicting:

<!-- Lazy + responsive: lazy defers the winning candidate's fetch -->
<img
  srcset="
    /assets/hero-400.webp  400w,
    /assets/hero-800.webp  800w,
    /assets/hero-1600.webp 1600w
  "
  sizes="(max-width: 640px) 100vw, 50vw"
  src="/assets/hero-800.webp"   <!-- fallback for browsers without srcset -->
  width="1600"
  height="900"
  alt="Dashboard screenshot showing real-time analytics"
  loading="lazy"
  decoding="async"
/>

Step 2 — Hero and Above-the-Fold Images: Override to eager + fetchpriority="high"

Hero images are LCP candidates. Applying loading="lazy" to above-the-fold images is one of the most common performance regressions in production sites — the browser delays the fetch by hundreds of milliseconds, pushing LCP past the 2.5 s threshold.

<!-- Hero image: force immediate fetch at elevated network priority -->
<img
  src="/assets/hero.webp"
  width="1600"
  height="900"
  alt="Hero: abstract visualisation of a content delivery network"
  loading="eager"           <!-- do NOT lazy-load LCP candidates -->
  fetchpriority="high"      <!-- elevate above parser-discovered CSS/fonts -->
  decoding="async"          <!-- decode off main thread even though fetch is immediate -->
/>

See using fetchpriority to optimize critical media for the full priority model and starvation risks when fetchpriority="high" is applied to multiple elements.

Step 3 — Iframes: Defer Third-Party Embeds

Third-party <iframe> elements carry their own JavaScript payloads that execute on load. Deferring them until scroll proximity can eliminate 100–400 ms of main-thread blocking on pages with maps, videos, or social widgets:

<!-- Lazy iframe: defers embed JS execution until user nears the element -->
<iframe
  src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
  width="560"
  height="315"
  title="Demo video: adaptive bitrate streaming walkthrough"  <!-- required for a11y -->
  loading="lazy"       <!-- supported Chrome 77+, Edge 79+, Safari 16+ only -->
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope"
  allowfullscreen
></iframe>

Warning: loading="lazy" on <iframe> is not supported in Firefox as of mid-2026. Use an IntersectionObserver facade if cross-browser iframe deferral is a requirement.

Step 4 — JavaScript Fallback for Legacy Browsers

Safari 14, older Chromium versions, and any browser that predates native lazy loading will ignore loading="lazy" and fetch all images immediately. A progressive-enhancement fallback using data-src and dynamic import prevents a regression to eager loading on those platforms:

// Feature-detect native lazy loading before activating any polyfill
if ('loading' in HTMLImageElement.prototype) {
  // Native support: browser handles everything — no JS needed
  // Ensure data-src images are promoted to src in case HTML was authored
  // with polyfill markup (data-src) rather than src
  document.querySelectorAll('img[data-src]').forEach(img => {
    img.src = img.dataset.src;        // promote deferred src to native attribute
    if (img.dataset.srcset) {
      img.srcset = img.dataset.srcset; // same for srcset
    }
  });
} else {
  // Legacy path: dynamically import lazysizes only when needed
  // lazysizes activates on elements with class="lazyload" + data-src
  import('/vendor/lazysizes.min.js').then(() => {
    document.querySelectorAll('img[data-src]').forEach(img => {
      img.classList.add('lazyload'); // lazysizes hooks on this class
    });
  });
}

Step 5 — CSS Background Images: IntersectionObserver Required

The loading attribute applies only to <img> and <iframe> elements. CSS background-image properties receive no native lazy treatment. Use IntersectionObserver to inject the background URL on intersection:

// Lazy-load CSS background images via IntersectionObserver
const bgObserver = new IntersectionObserver(
  (entries, obs) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;

      const el = entry.target;
      const url = el.dataset.bgSrc; // read deferred URL from data attribute

      if (url) {
        el.style.backgroundImage = `url(${url})`; // inject only when near viewport
        el.removeAttribute('data-bg-src');          // prevent double injection
      }

      obs.unobserve(el); // stop observing once loaded
    });
  },
  {
    rootMargin: '400px 0px', // pre-load 400 px before element enters viewport
  }
);

document.querySelectorAll('[data-bg-src]').forEach(el => bgObserver.observe(el));

For advanced IntersectionObserver configuration — root margin tuning, multiple thresholds, and stacked observer patterns — see Advanced IntersectionObserver Patterns for Media.

Step 6 — Single-Page Applications: Handle Dynamic DOM Mutations

Frameworks that inject <img> elements after the initial parse — React, Vue, Angular, SvelteKit — bypass the parser’s lazy-loading registration. Images injected into the DOM programmatically after DOMContentLoaded still respect loading="lazy" if the attribute is present at injection time, because Chromium re-evaluates the attribute on each new element. However, images injected without the attribute will not retrospectively receive lazy treatment.

// MutationObserver: audit dynamic images for missing loading attribute
// Use only when you don't control the injection source (e.g. CMS content)
const domAudit = new MutationObserver(mutations => {
  for (const mutation of mutations) {
    mutation.addedNodes.forEach(node => {
      if (node.nodeType !== Node.ELEMENT_NODE) return;

      // Handle directly added img/iframe
      if (node.matches('img, iframe') && !node.hasAttribute('loading')) {
        // Only set lazy if element is below the fold at injection time
        const rect = node.getBoundingClientRect();
        if (rect.top > window.innerHeight) {
          node.setAttribute('loading', 'lazy'); // late attribute add is honoured
        }
      }

      // Handle img/iframe nested inside an injected container
      node.querySelectorAll('img:not([loading]), iframe:not([loading])')
        .forEach(media => {
          const rect = media.getBoundingClientRect();
          if (rect.top > window.innerHeight) {
            media.setAttribute('loading', 'lazy');
          }
        });
    });
  }
});

domAudit.observe(document.body, { childList: true, subtree: true });

Step 7 — Pair Lazy Loading With sizes="auto" and contain-intrinsic-size

Two newer platform features exist specifically because an image is lazy. The first is sizes="auto", which is only valid on an element that has loading="lazy". On an eager image the browser must pick a srcset candidate before layout exists, so sizes has to be a media-query guess. A lazy image is different: by the time its fetch is allowed to start, layout has already run and the browser knows the element’s concrete rendered width. sizes="auto" tells it to use that measured width instead of your hand-written breakpoint list, which eliminates the entire class of bugs where a sizes expression drifts out of sync with the CSS.

<!-- sizes="auto" is ignored unless loading="lazy" is also present.
     The trailing fallback list after the comma is used by browsers that
     do not yet support auto — keep it accurate, it is not dead code. -->
<img
  srcset="/assets/card-320.avif 320w, /assets/card-640.avif 640w, /assets/card-1280.avif 1280w"
  sizes="auto, (max-width: 640px) 100vw, 33vw"
  src="/assets/card-640.avif"
  width="1280"
  height="720"
  alt="Card thumbnail: quarterly delivery report"
  loading="lazy"           <!-- mandatory precondition for sizes="auto" -->
  decoding="async"
  style="contain-intrinsic-size: 1280px 720px"  <!-- placeholder box size before layout -->
/>

Warning: sizes="auto" on an element without loading="lazy" is invalid and the browser falls back to 100vw, which will select your largest srcset candidate on every viewport. This is a silent 3–4× payload regression, and it is the single most common mistake when migrating a sizes list. For the manual calculation this replaces, see how to calculate optimal sizes attribute values.

The second feature is contain-intrinsic-size, which supplies a placeholder size for an element whose contents are not yet rendered. Explicit width/height attributes already handle the <img> case, but contain-intrinsic-size is what makes content-visibility: auto safe on the containers that wrap galleries of lazy images — without it, a skipped subtree reports zero height and the scrollbar jumps as the user scrolls into it.

/* Defer render work for off-screen gallery rows. Each row still occupies
   its declared intrinsic size, so the scroll height stays stable. */
.gallery-row {
  content-visibility: auto;
  contain-intrinsic-size: auto 420px;  /* 'auto' remembers the real size after first render */
}

Tradeoff: content-visibility: auto defers layout, paint, and style work — not network fetches. Images inside a skipped subtree are still fetched when they cross the distance threshold, because the threshold calculation uses the container’s intrinsic size. The two features compose; neither replaces the other.

Forced Loads: Print, Find-in-Page, and Anchor Navigation

Several user actions require the browser to materialise content that is nowhere near the viewport, and each one force-loads lazy images regardless of the distance threshold. Knowing which paths bypass deferral matters when you are budgeting bandwidth or debugging a “why did all 200 images download at once” report.

Printing. Chromium and Firefox eagerly load every lazy image in the document when a print preview is generated, whether triggered by the user or by window.print(). On a long catalogue page this can mean several megabytes fetched in one burst. If print fidelity matters, ship a @media print stylesheet that hides decorative imagery so the forced load is at least small.

Find-in-page. Text matches inside a content-visibility: auto subtree cause the browser to render that subtree, which in turn brings its images inside the distance threshold. This is correct behaviour but makes bandwidth measurement non-deterministic in manual testing — always measure with a scripted scroll, never by searching the page.

Same-document anchor navigation. Following a #section link scrolls instantly rather than progressively, so every threshold between the origin and the destination is crossed within a single frame. The browser coalesces this into one batch of fetches at the destination, but pages with hundreds of lazy images can still stall the network queue. If you have deep-link-heavy documentation pages, force the destination’s hero image eager with img.loading = 'eager' on hashchange rather than letting the batch resolve on its own.

Restored back/forward-cache pages. A page restored from bfcache keeps its already-loaded images but re-evaluates the threshold for the ones still deferred, using the restored scroll position. Images the user had scrolled past will therefore already be loaded; those below will not re-fetch.

Parameter Reference

loading="lazy" : Defers the fetch until the element enters the browser’s internal distance threshold. Valid on <img> and <iframe>. Has no effect on <video>, <script>, or <link> elements.

loading="eager" : The default for <img> (and explicit override). Instructs the browser to fetch the resource as soon as the element is parsed, without waiting for viewport proximity. Use on all above-the-fold images.

decoding="async" : Decouples image decode from main-thread rendering. Combine with loading="lazy" so that when the deferred fetch completes, decode also avoids blocking frame rendering. The alternative decoding="sync" (the default) blocks compositing until decode finishes.

fetchpriority="high" / "low" / "auto" : Overrides the browser’s default network priority queue position for the resource. "high" elevates above background fetches; "low" depresses below default. Not a substitute for loadingfetchpriority changes when the request starts relative to other queued requests, whereas loading controls whether the request is queued at all.

width / height : Must be set as integer attribute values matching the intrinsic pixel dimensions of the image. The browser uses these to compute aspect-ratio before the image loads, reserving the correct layout space and preventing CLS. Without them, lazy-loaded images collapse to zero height until the fetch resolves, causing the page to reflow.

sizes="auto" : Valid only in combination with loading="lazy". Instructs the browser to substitute the element’s measured layout width for the sizes expression when selecting a srcset candidate. Always follow it with a comma and a conventional fallback list for engines that do not support it. On an eager image it is invalid and degrades to 100vw.

contain-intrinsic-size : CSS property supplying a placeholder box size for an element whose contents are not rendered. Use the auto <length> form so the browser remembers the real size after the first render. Required for stable scrolling whenever content-visibility: auto wraps rows of lazy images.

img.loading (IDL) : The reflected property. Reading it returns the canonical keyword ("lazy" or "eager"), never the authored string. Assigning "eager" to an element parsed as lazy runs the spec’s lazy load resumption steps and starts the fetch immediately — the supported way to force-load one specific image.

Benchmark Data: Payload and Timing Impact

The figures below are from a 20-image product listing page (mixed JPEG/WebP, 1,400 × 1,050 px, ~120 KB each) tested on WebPageTest with a Motorola G (gen 4) throttled to a 3G Fast profile:

Metric All eager All lazy Hero eager + rest lazy
Initial network payload (bytes transferred) 2.4 MB 420 KB 580 KB
Time to First Byte 310 ms 310 ms 310 ms
LCP (ms) 4,200 7,800 2,900
CLS score 0.02 0.28 (no dimensions set) 0.01
CLS score (with dimensions) 0.02 0.04 0.01

Charted side by side, the two columns move in opposite directions for the naive strategies and only align for the mixed one — which is the whole argument for treating the fold as a policy boundary rather than a global switch.

LCP and initial payload for three loading strategies Two bar charts. Left chart, LCP in milliseconds: all eager 4200, all lazy 7800, hero eager with the rest lazy 2900, against a 2500 millisecond target line. Right chart, initial payload: all eager 2.4 megabytes, all lazy 420 kilobytes, hero eager with the rest lazy 580 kilobytes. Only the mixed strategy is good on both axes. 20-image listing page, 3G Fast profile on a Moto G4 LCP (ms) — lower is better Initial payload — lower is better 2.5 s target 4,200 7,800 2,900 All eager All lazy Hero eager, rest lazy 2.4 MB 420 KB 580 KB All eager All lazy Hero eager, rest lazy

Tradeoff: “All lazy” reduces initial payload by 82% but more than doubles LCP because the hero image is also deferred. Always keep above-the-fold images on loading="eager".

Tradeoff: CLS spikes to 0.28 when lazy images lack width/height. Adding explicit dimensions brings it down to 0.04 — still above the 0.1 threshold. Confirm dimensions match the intrinsic image size exactly.

Tradeoffs & Edge Cases

loading="lazy" delays LCP when applied to hero images. The most common production mistake. Lighthouse and Chrome DevTools will flag it, but it often reaches production because developers test on fast desktop connections where the threshold fires immediately. Test specifically under throttled mobile profiles.

Threshold is not a precise scroll position. Browsers fire the fetch before the element enters the viewport, not at the moment of scroll intersection. This means content that the user never scrolls to may still be fetched if they scroll past a threshold point above it. The network savings are real but not zero for below-the-fold content.

Images inside display:none containers are not fetched at all in Chromium, regardless of loading attribute. This is intentional — the browser skips layout for hidden elements and therefore cannot calculate viewport distance. If you toggle visibility with CSS (e.g. a modal), the image fetches when the container becomes visible. Plan for a small fetch delay on modal open.

content-visibility: auto on layout containers provides a partial alternative for deferring render work on off-screen sections. It does not defer network fetches — images inside a content-visibility: auto region are still fetched when they cross the distance threshold. Use it alongside loading="lazy", not instead of it.

Lazy loading and <link rel="preload"> are incompatible for the same resource. A preloaded image is fetched immediately regardless of the loading="lazy" attribute on the corresponding <img>. If you preload an image that you also mark as lazy, you have eliminated the lazy loading benefit. See preload vs prefetch for video and image assets for the correct prioritisation model.

WebM and other video formats used as animated backgrounds have no native lazy attribute. See how to implement lazy loading for WebM backgrounds for the IntersectionObserver approach specific to <video> elements.

Debugging & Validation

1. Confirm the Attribute Is Honoured (Chrome Network Panel)

Open DevTools → Network → filter by Img. Sort by Waterfall. Lazy images should not appear in the waterfall until after DOMContentLoaded and only when the user scrolls. If they appear immediately, check that loading="lazy" is present on the element (not just the template) and that the image is not within the initial viewport.

2. Inspect the Priority Column

In the Network panel, enable the Priority column (right-click the column headers). Lazy images will show Low or Lowest before they are triggered. After scroll triggers the fetch, they move to High. fetchpriority="high" images always show High from the start. Any LCP candidate showing Low needs loading="eager" and fetchpriority="high".

3. Measure CLS with Lighthouse

# Run Lighthouse via the CLI against a local build
npx lighthouse http://localhost:8080/products/ \
  --only-categories=performance \
  --output=json \
  --output-path=./lh-lazy.json

# Extract CLS and LCP scores
node -e "
  const r = require('./lh-lazy.json');
  const p = r.lhr.categories.performance;
  console.log('Score:', p.score);
  console.log('LCP:', r.lhr.audits['largest-contentful-paint'].displayValue);
  console.log('CLS:', r.lhr.audits['cumulative-layout-shift'].displayValue);
"

Target: LCP under 2.5 s on simulated mobile 4G, CLS under 0.1.

4. Check loading Attribute in the Rendered DOM

# Verify lazy images are in the output HTML (for SSR / static builds)
curl -s https://media-delivery.com/products/ \
  | grep -oP 'loading="[^"]+"' \
  | sort | uniq -c

Expected output: one or two loading="eager" entries (hero images), many loading="lazy" entries.

5. Validate No CLS-Causing Images Lack Dimensions

# Parse built HTML: flag img elements without width AND height
node -e "
  const { JSDOM } = require('jsdom');
  const fs = require('fs');
  const html = fs.readFileSync('./_site/products/index.html', 'utf8');
  const { document } = new JSDOM(html).window;
  document.querySelectorAll('img[loading=\"lazy\"]').forEach(img => {
    if (!img.width || !img.height) {
      console.warn('Missing dimensions:', img.src || img.dataset.src);
    }
  });
"

6. WebPageTest Filmstrip Review

Run a WebPageTest test with the “3G Fast” throttle profile and examine the filmstrip. Lazy images should not appear until the corresponding scroll position in the filmstrip. If they appear at 0 s or before user interaction, the threshold is being crossed immediately — the image is too close to the top of the page and should use loading="eager".

7. Assert Attribute Correctness in CI

Because loading has an invalid-value default of eager, a misspelling never throws — it just quietly disables deferral. Guard against it with a build-time assertion rather than a manual review:

# Fail the build on any loading attribute value that is not exactly lazy or eager,
# and on any lazy image missing width/height. Runs against the built output,
# so it catches template bugs that only appear after rendering.
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 failed = 0;
  for (const f of files) {
    const { document } = new JSDOM(fs.readFileSync(path.join('_site', f), 'utf8')).window;
    document.querySelectorAll('img[loading], iframe[loading]').forEach(el => {
      const v = el.getAttribute('loading');
      // Canonical keywords only — 'lazyload', 'Lazy', '' all silently mean eager
      if (v !== 'lazy' && v !== 'eager') {
        console.error(f, 'bad loading value:', JSON.stringify(v)); failed++;
      }
      if (v === 'lazy' && !(el.getAttribute('width') && el.getAttribute('height'))) {
        console.error(f, 'lazy without dimensions:', el.getAttribute('src')); failed++;
      }
      // sizes=auto is only honoured on lazy elements
      if ((el.getAttribute('sizes') || '').startsWith('auto') && v !== 'lazy') {
        console.error(f, 'sizes=auto without loading=lazy:', el.getAttribute('src')); failed++;
      }
    });
  }
  process.exit(failed ? 1 : 0);
"

Wire this into the same job that runs your image-weight budget — see Lighthouse CI budget enforcement for image weight — so a regression in either dimension blocks the merge.

Frequently Asked Questions

Does loading="lazy" work on <video>, <audio>, or <script>?

No. The attribute is defined only on HTMLImageElement and HTMLIFrameElement. On any other element it is an unrecognised attribute and is ignored entirely — no console warning is emitted. <video> has preload="none", which is a related but weaker control, and it does not reserve layout space.

Should every below-the-fold image be lazy?

Not automatically. Each deferred image trades a saved byte for a possible delay when the user does scroll. Images just below the fold on a short page are almost always scrolled to, so the deferral buys little and risks a visible pop-in. A workable rule is: eager for anything within roughly one viewport height of the top, lazy beyond that, and never lazy on the LCP candidate.

Why did CLS not drop to zero after I added width and height?

Because the attributes must match the intrinsic dimensions of the file being served, not the CSS display size. If your CDN resizes on the fly and returns a 1200×800 image for a tag declaring 800×600, the computed aspect-ratio is wrong and the element resizes on decode. Verify with identify -format '%w %h' file.webp against the attribute values.

Does lazy loading hurt SEO or image indexing?

Not with native loading="lazy". Googlebot renders pages with a very tall viewport, so the distance threshold is satisfied for essentially all lazy images and they are fetched. JavaScript-based deferral that only sets src on a real scroll event is a different matter — those images may never load in a headless renderer. This is one more reason to prefer the native attribute over a scroll-listener polyfill.

Can I change the threshold?

No. It is not exposed to authors in any engine and there is no proposal to expose it. If you need a specific distance, replace the attribute with an IntersectionObserver and set rootMargin yourself. Mixing the two on the same element is redundant — the native threshold will usually win.

What happens to a lazy image inside a horizontally scrolling carousel? The distance calculation is two-dimensional, so a slide four positions to the right is deferred just as a below-the-fold image is. Native deferral therefore works for carousels, but the thresholds are tuned for vertical scroll and a fast swipe will outrun them. Carousels are the clearest case for an observer with a wide horizontal rootMargin.