Loading
Find Non Lazy Loaded Images Outside of the Viewport

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:

FieldDescription
selectorCSS selector path to identify the element
srcImage URL (truncated if long)
dimensionsNatural width × height
sizeFormattedFile size from Performance API
distanceFromViewportHow far below the viewport

Hidden Containers Section:

Images inside tabs, modals, carousels, or other hidden elements:

FieldDescription
selectorCSS selector path
hiddenReasonWhy it's considered hidden (e.g., .modal:not(.show))
containerThe parent container hiding the image

The snippet detects images in:

  • display: none or visibility: hidden elements
  • [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:

  1. transferSize: Actual bytes transferred over the network (includes headers)
  2. 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:

  1. LCP Images: Never lazy-load your LCP image. This will delay it and hurt Core Web Vitals.
  2. Layout Shift: Always specify width and height attributes to prevent CLS when images load.
  3. Loading attribute browser support: loading="lazy" has excellent browser support (opens in a new tab) (96%+ globally as of 2024).
  4. 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): 12

What 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: