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.
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.
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+) |
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.
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 });
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 loading — fetchpriority 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.
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 |
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".
Related
- Advanced IntersectionObserver Patterns for Media — custom root margins, multiple thresholds, and scroll-driven fetch orchestration beyond what the
loadingattribute supports - Using fetchpriority to Optimize Critical Media — controlling network queue priority for LCP images alongside lazy loading
- Preload vs Prefetch for Video and Image Assets — when to speculatively fetch resources before they are needed, and how preload interacts with lazy loading
- How to Implement Lazy Loading for WebM Backgrounds —
<video>elements and CSS video backgrounds have no native lazy attribute; this page covers the IntersectionObserver solution - Cache-Control Headers for Image and Video Assets — set long-lived cache headers so lazily-loaded assets are served from browser cache on repeat visits