# Find non lazy loaded images outside of the viewport

> Source: <https://webperf-snippets.nucliweb.net/Loading/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.

> **Note:** **Found lazy-loaded images in the viewport?** Use the [Find Above The Fold Lazy Loaded Images](https://webperf-snippets.nucliweb.net/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.md) snippet to detect that anti-pattern.

### Snippet

```js
// 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 {}
    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 {}
      }
      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;
  }

  // @shared formatBytes
  function formatBytes(bytes) {
    if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
    if (bytes === 0) return "0 B";
    const units = ["B", "KB", "MB", "GB"];
    const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
    return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
  }
  // @end-shared formatBytes

  // 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((img) => ({
        selector: img.selector,
        src: img.src,
        dimensions: img.dimensions,
        size: img.size,
        sizeFormatted: img.sizeFormatted,
        distanceFromViewport: img.distanceFromViewport,
      }));
      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((img) => ({
        selector: img.selector,
        src: img.src,
        dimensions: img.dimensions,
        size: img.size,
        sizeFormatted: img.sizeFormatted,
        hiddenReason: img.hiddenReason,
        container: img.container,
      }));
      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();

  return {
    script: "Find-non-Lazy-Loaded-Images-outside-of-the-viewport",
    status: "ok",
    count: totalImages,
    details: {
      belowFoldCount: results.belowFold.length,
      hiddenContainerCount: results.hiddenContainers.length,
      totalSizeBytes: totalSize,
      excluded: {
        inViewport: results.excluded.inViewport,
        lcpCandidate: results.excluded.lcpCandidate,
        tooSmall: results.excluded.tooSmall,
      },
    },
    items: [
      ...results.belowFold.map((img) => ({
        selector: img.selector,
        src: img.fullSrc,
        sizeBytes: img.size,
        category: "below-fold",
        distancePx: parseInt(img.distanceFromViewport),
      })),
      ...results.hiddenContainers.map((img) => ({
        selector: img.selector,
        src: img.fullSrc,
        sizeBytes: img.size,
        category: "hidden-container",
        hiddenReason: img.hiddenReason,
        container: img.container,
      })),
    ],
    issues: totalImages > 0
      ? [{ severity: "warning", message: `${totalImages} image(s) outside the viewport are missing loading="lazy"` }]
      : [],
  };
})();
```

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

```html
<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](https://caniuse.com/loading-lazy-attr) (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

### Browser support

| Feature                                                                                                                     | Chrome | Edge | Firefox | Safari |
| --------------------------------------------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :----: |
| [Element.getBoundingClientRect](https://developer.mozilla.org/docs/Web/API/Element/getBoundingClientRect)                   |   ✅ 2  | ✅ 12 |   ✅ 3   |   ✅ 4  |
| [HTMLImageElement.currentSrc](https://developer.mozilla.org/docs/Web/API/HTMLImageElement/currentSrc)                       |  ✅ 38  | ✅ 13 |   ✅ 38  |  ✅ 9.1 |
| [PerformanceResourceTiming.transferSize](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/transferSize) |  ✅ 54  | ✅ 17 |   ✅ 45  | ✅ 16.4 |
| All of the above                                                                                                            |  ✅ 54  | ✅ 17 |   ✅ 45  | ✅ 16.4 |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

### Further reading

- [Browser-level image lazy-loading for the web](https://web.dev/articles/browser-level-image-lazy-loading) | web.dev
- [The Complete Guide to Lazy Loading Images](https://css-tricks.com/the-complete-guide-to-lazy-loading-images/) | CSS-Tricks
