Find non Lazy Loaded Images outside of the viewport
Identifies images that are loaded eagerly but not visible in the initial viewport, representing wasted bandwidth and parsing time that delays page interactivity. The snippet analyzes all <img> elements to find optimization opportunities for lazy loading.
Images outside the viewport that load immediately:
- Waste bandwidth by downloading resources users may never see
- Block the main thread during decoding and rendering
- Delay LCP by competing for network and CPU resources
- Increase memory usage unnecessarily
This script detects images without loading="lazy" or [data-src] attributes that are positioned outside the initial viewport, including images in hidden containers (tabs, modals, carousels). It also identifies the LCP candidate to ensure you don't accidentally lazy-load it.
Found lazy-loaded images in the viewport? Use the Find Above The Fold Lazy Loaded Images snippet to detect that anti-pattern.
Snippet
// Execute after page load without user interaction (scroll, click, etc)
// https://webperf-snippets.nucliweb.net
(function () {
function getSelector(node) {
let sel = "";
try {
while (node && node.nodeType !== 9) {
const el = node;
const name = el.nodeName.toLowerCase();
const part = el.id
? "#" + el.id
: name +
(el.classList && el.classList.value && el.classList.value.trim()
? "." + el.classList.value.trim().split(/\s+/).slice(0, 2).join(".")
: "");
if (sel.length + part.length > 80) return sel || part;
sel = sel ? part + ">" + sel : part;
if (el.id) break;
node = el.parentNode;
}
} catch (err) {}
return sel;
}
function isInViewport(element) {
const rect = element.getBoundingClientRect();
return (
rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0 &&
rect.width > 0 &&
rect.height > 0
);
}
function isInHiddenContainer(element) {
let parent = element.parentElement;
const hiddenSelectors = [
"[hidden]",
'[aria-hidden="true"]',
".modal:not(.show)",
".tab-pane:not(.active)",
'[role="tabpanel"]:not(.active)',
".accordion-collapse:not(.show)",
".carousel-item:not(.active)",
".swiper-slide:not(.swiper-slide-active)",
];
while (parent && parent !== document.body) {
const cs = window.getComputedStyle(parent);
if (cs.display === "none" || cs.visibility === "hidden") {
return { hidden: true, reason: "CSS hidden", container: getSelector(parent) };
}
for (const selector of hiddenSelectors) {
try {
if (parent.matches(selector)) {
return { hidden: true, reason: selector, container: getSelector(parent) };
}
} catch (e) {}
}
parent = parent.parentElement;
}
return { hidden: false };
}
function getImageSize(imgElement) {
const src = imgElement.currentSrc || imgElement.src;
if (!src || src.startsWith("data:")) return 0;
const perfEntries = performance.getEntriesByType("resource");
const imgEntry = perfEntries.find((entry) => entry.name === src);
if (imgEntry) {
return imgEntry.transferSize || imgEntry.encodedBodySize || 0;
}
return 0;
}
function formatBytes(bytes) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
// Find LCP candidate (largest visible image) to exclude from recommendations
const allViewportImages = Array.from(document.querySelectorAll("img")).filter(
(img) => isInViewport(img) && img.getBoundingClientRect().width > 0
);
let lcpCandidate = null;
let maxArea = 0;
allViewportImages.forEach((img) => {
const rect = img.getBoundingClientRect();
const area = rect.width * rect.height;
if (area > maxArea) {
maxArea = area;
lcpCandidate = img;
}
});
// Find images without lazy loading
const notLazyImages = document.querySelectorAll('img:not([data-src]):not([loading="lazy"])');
const results = {
belowFold: [],
hiddenContainers: [],
excluded: {
inViewport: 0,
lcpCandidate: null,
tooSmall: 0,
},
elements: [],
};
notLazyImages.forEach((img) => {
const rect = img.getBoundingClientRect();
// Skip images in viewport (they shouldn't be lazy loaded)
if (isInViewport(img)) {
if (img === lcpCandidate) {
results.excluded.lcpCandidate = getSelector(img);
} else {
results.excluded.inViewport++;
}
return;
}
// Skip very small images (likely icons/tracking pixels)
if (rect.width < 50 || rect.height < 50) {
results.excluded.tooSmall++;
return;
}
const src = img.currentSrc || img.src;
const size = getImageSize(img);
const hiddenCheck = isInHiddenContainer(img);
const imageData = {
selector: getSelector(img),
src: src.length > 60 ? "..." + src.slice(-57) : src,
fullSrc: src,
dimensions: `${img.naturalWidth}×${img.naturalHeight}`,
size: size,
sizeFormatted: size > 0 ? formatBytes(size) : "unknown",
element: img,
};
if (hiddenCheck.hidden) {
imageData.hiddenReason = hiddenCheck.reason;
imageData.container = hiddenCheck.container;
results.hiddenContainers.push(imageData);
} else {
imageData.distanceFromViewport = Math.round(rect.top - window.innerHeight) + "px";
results.belowFold.push(imageData);
}
results.elements.push(img);
});
// Sort below-fold images by distance (furthest first)
results.belowFold.sort((a, b) => parseInt(b.distanceFromViewport) - parseInt(a.distanceFromViewport));
const totalImages = results.belowFold.length + results.hiddenContainers.length;
const totalSize = [...results.belowFold, ...results.hiddenContainers].reduce((sum, img) => sum + img.size, 0);
// Display results
console.group("💡 Lazy Loading Opportunities");
if (totalImages === 0) {
console.log(
"%c✅ Good job! All images outside the viewport have lazy loading.",
"background: #222; color: #22c55e; padding: 0.5ch 1ch; font-weight: bold"
);
} else {
console.log(
`%c⚠️ Found ${totalImages} image(s) that should have lazy loading`,
"color: #f59e0b; font-weight: bold; font-size: 14px"
);
if (totalSize > 0) {
console.log(
`%c📊 Potential savings: ${formatBytes(totalSize)} on initial load`,
"color: #22c55e; font-weight: bold"
);
}
console.log("");
// Below the fold images
if (results.belowFold.length > 0) {
console.group(`📍 Below The Fold (${results.belowFold.length} images)`);
const tableData = results.belowFold.slice(0, 20).map(({ element, fullSrc, ...rest }) => rest);
console.table(tableData);
if (results.belowFold.length > 20) {
console.log(`... and ${results.belowFold.length - 20} more images`);
}
console.groupEnd();
}
// Hidden container images
if (results.hiddenContainers.length > 0) {
console.log("");
console.group(`🔒 In Hidden Containers (${results.hiddenContainers.length} images)`);
console.log("Images in tabs, modals, carousels, or other hidden elements:");
console.log("");
const tableData = results.hiddenContainers.slice(0, 15).map(({ element, fullSrc, distanceFromViewport, ...rest }) => rest);
console.table(tableData);
if (results.hiddenContainers.length > 15) {
console.log(`... and ${results.hiddenContainers.length - 15} more images`);
}
console.groupEnd();
}
// Excluded summary
console.log("");
console.group("ℹ️ Correctly Excluded (should NOT be lazy loaded)");
console.log(`• LCP candidate: ${results.excluded.lcpCandidate || "none detected"}`);
console.log(`• Other in-viewport images: ${results.excluded.inViewport}`);
console.log(`• Too small (<50px): ${results.excluded.tooSmall}`);
console.groupEnd();
// Elements for inspection
console.log("");
console.group("🔎 Elements for inspection");
console.log("Click to inspect in Elements panel:");
results.elements.slice(0, 15).forEach((img, i) => console.log(`${i + 1}.`, img));
if (results.elements.length > 15) {
console.log(`... and ${results.elements.length - 15} more`);
}
console.groupEnd();
// Quick fix
console.log("");
console.group("📝 Quick Fix");
console.log("Add lazy loading to these images:");
console.log("");
console.log(
'%c<img src="image.jpg" loading="lazy" alt="...">',
"font-family: monospace; background: #1e1e1e; color: #9cdcfe; padding: 8px; border-radius: 4px"
);
console.groupEnd();
}
console.groupEnd();
})();Understanding the Results
The snippet provides different outputs depending on what it finds:
Success Case: All Images Optimized
If all images outside the viewport already use lazy loading:
✅ Good job! All images outside the viewport have lazy loading.This means your page is already optimized and no action is needed.
Optimization Opportunities Found
When images without lazy loading are detected, you'll see organized output with:
Summary:
- Total count of images that should have lazy loading
- Potential bandwidth savings on initial load
Below The Fold Section:
Images positioned below the viewport, displayed in a table with:
| Field | Description |
|---|---|
selector | CSS selector path to identify the element |
src | Image URL (truncated if long) |
dimensions | Natural width × height |
sizeFormatted | File size from Performance API |
distanceFromViewport | How far below the viewport |
Hidden Containers Section:
Images inside tabs, modals, carousels, or other hidden elements:
| Field | Description |
|---|---|
selector | CSS selector path |
hiddenReason | Why it's considered hidden (e.g., .modal:not(.show)) |
container | The parent container hiding the image |
The snippet detects images in:
display: noneorvisibility: hiddenelements[hidden]or[aria-hidden="true"]attributes- Inactive tab panels (
.tab-pane:not(.active)) - Closed accordions (
.accordion-collapse:not(.show)) - Non-active carousel slides (
.carousel-item:not(.active),.swiper-slide) - Modals (
.modal:not(.show))
Correctly Excluded Section:
Shows what was intentionally skipped:
- LCP candidate: The largest viewport image (should never be lazy-loaded)
- Other in-viewport images: Above-the-fold images
- Too small: Images under 50px (likely icons/tracking pixels)
How Size Detection Works
The script uses the Performance Resource Timing API to get accurate file sizes:
transferSize: Actual bytes transferred over the network (includes headers)encodedBodySize: Compressed response body size (fallback if transferSize unavailable)
Why this is more reliable than fetch:
- ✅ No CORS issues (data is already available in the browser)
- ✅ No additional network requests needed
- ✅ Reflects actual transfer size, including compression
- ✅ Works with all image sources (same-origin and cross-origin)
Note: Images must be loaded before running the script for size detection to work. Execute it after page load is complete.
What to Do With This Information
When the script identifies non-lazy-loaded images:
1. Add Native Lazy Loading
For modern browsers, simply add the loading attribute:
<img src="image.jpg" alt="Description" loading="lazy" />2. Prioritize by Impact
Focus on images with:
- Largest file sizes (biggest bandwidth savings)
- Lowest position on page (least likely to be seen immediately)
- High resolution (more CPU-intensive to decode)
3. Measure Performance Impact
Before and after implementing lazy loading:
- Check Largest Contentful Paint (LCP) - should improve if LCP wasn't an above-fold image
- Measure Total Blocking Time (TBT) - should reduce with fewer images to decode
- Track Network usage - compare total bytes transferred on initial load
Best Practices
When to use lazy loading:
- ✅ Images below the fold (outside initial viewport)
- ✅ Images in long articles or infinite scroll
- ✅ Images in carousels (except the first visible slide)
- ✅ Images in tabs/accordions (hidden content)
When NOT to use lazy loading:
- ❌ The LCP (Largest Contentful Paint) element
- ❌ Above-the-fold hero images
- ❌ Small images that are part of initial UI (
<10KB) - ❌ Images critical for First Contentful Paint
Important Considerations:
- LCP Images: Never lazy-load your LCP image. This will delay it and hurt Core Web Vitals.
- Layout Shift: Always specify
widthandheightattributes to prevent CLS when images load. - Loading attribute browser support:
loading="lazy"has excellent browser support (opens in a new tab) (96%+ globally as of 2024). - SEO: Search engines can crawl lazy-loaded images, but ensure proper alt text and semantic markup.
Example Output Interpretation
💡 Lazy Loading Opportunities
⚠️ Found 42 image(s) that should have lazy loading
📊 Potential savings: 2.35 MB on initial load
📍 Below The Fold (35 images)
┌─────────┬──────────────────────────┬────────────┬─────────────┬─────────────────────┐
│ selector│ src │ dimensions │ sizeFormatted│ distanceFromViewport│
├─────────┼──────────────────────────┼────────────┼─────────────┼─────────────────────┤
│ img.product│ ...product-gallery-3.jpg │ 800×600 │ 89.45 KB │ 1200px │
└─────────┴──────────────────────────┴────────────┴─────────────┴─────────────────────┘
🔒 In Hidden Containers (7 images)
Images in tabs, modals, carousels, or other hidden elements:
┌─────────┬────────────────────────┬─────────────────────────┬──────────────────┐
│ selector│ hiddenReason │ container │ sizeFormatted │
├─────────┼────────────────────────┼─────────────────────────┼──────────────────┤
│ img.slide│ .carousel-item:not(...)│ div.carousel-inner │ 245.67 KB │
└─────────┴────────────────────────┴─────────────────────────┴──────────────────┘
ℹ️ Correctly Excluded (should NOT be lazy loaded)
• LCP candidate: img.hero-image
• Other in-viewport images: 3
• Too small (<50px): 12What this tells you:
- 42 images should have lazy loading but don't
- You're wasting 2.35 MB of bandwidth on initial load
- 35 images are simply below the fold
- 7 images are in hidden containers (carousel slides, tabs, etc.)
- The LCP candidate and viewport images are correctly excluded
- Adding
loading="lazy"would significantly improve load times
Further Reading
For comprehensive guides on image optimization and lazy loading:
- 📖 Browser-level image lazy-loading for the web (opens in a new tab) - Chrome Developers
- 📖 Lazy loading images (opens in a new tab) - web.dev
- 📖 The Complete Guide to Lazy Loading Images (opens in a new tab) - CSS-Tricks