# LCP image entropy

> Source: <https://webperf-snippets.net/CoreWebVitals/LCP-Image-Entropy>

Checks if images qualify as LCP candidates based on their entropy (bits per pixel). Since [Chrome 112](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/speed/metrics_changelog/2023_04_lcp.md), low-entropy images are ignored for LCP measurement.

Based on [Stoyan Stefanov's BPP check](https://www.phpied.com/quick-bpp-image-entropy-check/).

### What is BPP (bits per pixel)?

BPP measures image complexity/entropy:

```
BPP = (file size in bits) / (width × height)
    = (encodedBodySize × 8) / (width × height)
```

### LCP eligibility threshold

| BPP    | Entropy   | LCP Eligible | Example                                      |
| ------ | --------- | ------------ | -------------------------------------------- |
| < 0.05 | 🔴 Low    | ❌ No         | Solid colors, simple gradients, placeholders |
| ≥ 0.05 | 🟢 Normal | ✅ Yes        | Photos, complex graphics                     |

> **Note:** **Why this matters:** If your largest image has low entropy, Chrome will skip it and use the next largest element for LCP. This can cause unexpected LCP values.

### Snippet

```js
// LCP Image Entropy Check
// https://webperf-snippets.net

(async () => {
  if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
    console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
    return { script: "LCP-Image-Entropy", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
  }
  // @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

  const LCP_THRESHOLD = 0.05; // Chrome's threshold for low-entropy

  // Get current LCP element
  let lcpElement = null;
  let lcpUrl = null;

  const lcpObserver = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    if (lastEntry) {
      lcpElement = lastEntry.element;
      lcpUrl = lastEntry.url;
    }
  });

  lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });

  // Wait a tick to ensure LCP is captured (for human console output)
  setTimeout(() => {
    lcpObserver.disconnect();

    // Get all images
    const images = [...document.images]
      .filter((img) => {
        const src = img.currentSrc || img.src;
        return src && !src.startsWith("data:image");
      })
      .map((img) => {
        const src = img.currentSrc || img.src;
        const resource = performance.getEntriesByName(src)[0];
        const fileSize = resource?.encodedBodySize || 0;
        const pixels = img.naturalWidth * img.naturalHeight;
        const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;

        const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
        const isLCP = lcpElement === img || lcpUrl === src;

        return {
          element: img,
          src,
          shortSrc: src.split("/").pop()?.split("?")[0] || src,
          width: img.naturalWidth,
          height: img.naturalHeight,
          fileSize,
          bpp,
          isLowEntropy,
          isLCP,
          lcpEligible: !isLowEntropy && bpp > 0,
        };
      })
      .filter((img) => img.bpp > 0); // Only images with measurable BPP

    console.group("%c🖼️ Image Entropy Analysis", "font-weight: bold; font-size: 14px;");

    if (images.length === 0) {
      console.log("   No images with measurable entropy found.");
      console.log("   (Data URLs and cross-origin images without CORS are excluded)");
      console.groupEnd();
      return;
    }

    // Summary
    const lowEntropy = images.filter((img) => img.isLowEntropy);
    const normalEntropy = images.filter((img) => !img.isLowEntropy);
    const lcpImage = images.find((img) => img.isLCP);

    console.log("");
    console.log("%cSummary:", "font-weight: bold;");
    console.log(`   Total images analyzed: ${images.length}`);
    console.log(`   🟢 Normal entropy (LCP eligible): ${normalEntropy.length}`);
    console.log(`   🔴 Low entropy (LCP ineligible): ${lowEntropy.length}`);

    if (lcpImage) {
      const icon = lcpImage.isLowEntropy ? "⚠️" : "✅";
      console.log("");
      console.log(`%c${icon} Current LCP image:`, "font-weight: bold;");
      console.log(`   ${lcpImage.shortSrc}`);
      console.log(`   BPP: ${lcpImage.bpp.toFixed(4)} ${lcpImage.isLowEntropy ? "(LOW - may be skipped!)" : "(OK)"}`);
    }

    // Table
    console.log("");
    console.log("%cAll Images:", "font-weight: bold;");

    const tableData = images
      .sort((a, b) => b.bpp - a.bpp)
      .map((img) => ({
        Image: img.shortSrc.length > 30 ? "..." + img.shortSrc.slice(-27) : img.shortSrc,
        Dimensions: `${img.width}×${img.height}`,
        Size: formatBytes(img.fileSize),
        BPP: img.bpp.toFixed(4),
        Entropy: img.isLowEntropy ? "🔴 Low" : "🟢 Normal",
        "LCP Eligible": img.lcpEligible ? "✅" : "❌",
        "Is LCP": img.isLCP ? "👈" : "",
      }));

    console.table(tableData);

    // Warnings
    if (lowEntropy.length > 0) {
      console.log("");
      console.log("%c⚠️ Low Entropy Images:", "font-weight: bold; color: #f59e0b;");
      console.log("   These images will NOT be considered for LCP in Chrome 112+:");
      lowEntropy.forEach((img) => {
        console.log(`   • ${img.shortSrc} (BPP: ${img.bpp.toFixed(4)})`, img.element);
      });
    }

    if (lcpImage && lcpImage.isLowEntropy) {
      console.log("");
      console.log("%c🚨 Warning:", "font-weight: bold; color: #ef4444;");
      console.log("   Your LCP image has low entropy and may be skipped by Chrome!");
      console.log("   Chrome will use the next largest element instead.");
      console.log("");
      console.log("%c💡 Solutions:", "font-weight: bold; color: #3b82f6;");
      console.log("   • Replace placeholder with actual content image");
      console.log("   • Use a text element as LCP instead");
      console.log("   • Ensure hero image loads with sufficient detail");
    }

    // Elements for inspection
    console.log("");
    console.log("%c🔎 Inspect elements:", "font-weight: bold;");
    images.forEach((img, i) => {
      const icon = img.isLowEntropy ? "🔴" : "🟢";
      const lcpMark = img.isLCP ? " 👈 LCP" : "";
      console.log(`   ${i + 1}. ${icon} ${img.shortSrc}${lcpMark}`, img.element);
    });

    console.groupEnd();
  }, 100);

  // Return for agent (buffered entries + DOM)
  // Chrome only exposes largest-contentful-paint entries through a PerformanceObserver,
  // so performance.getEntriesByType() returns [] here. Collect via a buffered observer.
  const lcpEntriesSync = await new Promise((resolve) => {
    const entries = [];
    const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
    obs.observe({ type: "largest-contentful-paint", buffered: true });
    setTimeout(() => { obs.disconnect(); resolve(entries); }, 100);
  });
  const lcpEntrySync = lcpEntriesSync.at(-1);
  const lcpElementSync = lcpEntrySync?.element ?? null;
  const lcpUrlSync = lcpEntrySync?.url ?? null;
  const imagesSync = [...document.images]
    .filter((img) => { const src = img.currentSrc || img.src; return src && !src.startsWith("data:image"); })
    .map((img) => {
      const src = img.currentSrc || img.src;
      const resource = performance.getEntriesByName(src)[0];
      const fileSize = resource?.encodedBodySize || 0;
      const pixels = img.naturalWidth * img.naturalHeight;
      const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
      const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
      const isLCP = lcpElementSync === img || lcpUrlSync === src;
      return {
        url: src.split("/").pop()?.split("?")[0] || src,
        width: img.naturalWidth,
        height: img.naturalHeight,
        fileSizeBytes: fileSize,
        bpp: Math.round(bpp * 10000) / 10000,
        isLowEntropy,
        lcpEligible: !isLowEntropy && bpp > 0,
        isLCP,
      };
    })
    .filter((img) => img.bpp > 0);
  const lowEntropyCount = imagesSync.filter((img) => img.isLowEntropy).length;
  const lcpImageSync = imagesSync.find((img) => img.isLCP);
  if (lcpElementSync) {
    lcpElementSync.style.outline = "3px dashed lime";
    lcpElementSync.style.outlineOffset = "2px";
  }
  const issuesSync = [];
  if (lowEntropyCount > 0) {
    issuesSync.push({ severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
  }
  if (lcpImageSync?.isLowEntropy) {
    issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
  }
  // Entropy applies to the LCP only when it is an <img> whose file size can be read; for text, or an
  // image this snippet cannot read, it says so. The images of the page are still listed.
  const lcpTag = lcpElementSync ? lcpElementSync.tagName.toLowerCase() : null;
  if (lcpTag && !lcpImageSync) {
    issuesSync.push({
      severity: "info",
      message: lcpTag === "img"
        ? "The LCP <img> has no file size to read, as with a data: URL or a cross-origin image without Timing-Allow-Origin, so its entropy cannot be computed."
        : lcpUrlSync
        ? `The LCP is the image of a <${lcpTag}>, such as a CSS background or a video poster, and this snippet reads <img> elements only. Run the LCP snippet to measure it.`
        : `The LCP element is <${lcpTag}>, text and not an image, so its entropy does not apply. Run the LCP snippet to measure it.`,
    });
  }
  // The list keeps the 50 most relevant images (the LCP image, then low entropy, then lowest bits per pixel); count and details cover all of them
  const MAX_ITEMS = 50;
  const rankedImages = [...imagesSync].sort(
    (a, b) => Number(b.isLCP) - Number(a.isLCP) || Number(b.isLowEntropy) - Number(a.isLowEntropy) || a.bpp - b.bpp
  );
  return {
    script: "LCP-Image-Entropy",
    status: "ok",
    count: imagesSync.length,
    details: {
      totalImages: imagesSync.length,
      lowEntropyCount,
      lcpElement: lcpTag,
      lcpImageEligible: lcpImageSync ? !lcpImageSync.isLowEntropy : null,
      lcpImage: lcpImageSync ? { url: lcpImageSync.url, bpp: lcpImageSync.bpp, isLowEntropy: lcpImageSync.isLowEntropy } : null,
    },
    items: rankedImages.slice(0, MAX_ITEMS),
    issues: issuesSync,
  };
})();
```

### Understanding the results

### Table columns

| Column       | Description                         |
| ------------ | ----------------------------------- |
| Image        | Filename                            |
| Dimensions   | Width × Height in pixels            |
| Size         | Encoded file size                   |
| BPP          | Bits per pixel (entropy measure)    |
| Entropy      | 🔴 Low (< 0.05) or 🟢 Normal        |
| LCP Eligible | Whether Chrome considers it for LCP |
| Is LCP       | `👈` marks the current LCP element  |

### When the LCP is not an `<img>`

Entropy applies to the LCP only when it is an `<img>` whose file size the snippet can read. When the LCP is text, the image of another element such as a CSS background or a video poster, or an `<img>` with no readable size (a `data:` URL, or a cross-origin image without `Timing-Allow-Origin`), the result says so and points to the [LCP](https://webperf-snippets.net/CoreWebVitals/LCP.md) snippet to measure it. The images of the page are still checked, since a low-entropy image cannot become the LCP.

### What causes low entropy

- Solid color images
- Simple gradients
- Loading placeholders (blur-up, LQIP)
- Very small images scaled up
- Highly compressed images with little detail

### Common issues

| Issue              | Impact                              | Solution                          |
| ------------------ | ----------------------------------- | --------------------------------- |
| Placeholder as LCP | Chrome skips it, unexpected LCP     | Load real image faster            |
| Gradient hero      | Not counted as LCP                  | Use image with detail or text LCP |
| Lazy-loaded hero   | Initial low-res placeholder skipped | Eager load hero image             |

### Browser support

| Feature                                                                                                                           | Chrome | Edge | Firefox | Safari |
| --------------------------------------------------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :----: |
| [LargestContentfulPaint](https://developer.mozilla.org/docs/Web/API/LargestContentfulPaint)                                       |  ✅ 77  | ✅ 79 |  ✅ 122  | ✅ 26.2 |
| [PerformanceObserver](https://developer.mozilla.org/docs/Web/API/PerformanceObserver)                                             |  ✅ 52  | ✅ 79 |   ✅ 57  |  ✅ 11  |
| [PerformanceResourceTiming.encodedBodySize](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/encodedBodySize) |  ✅ 54  | ✅ 17 |   ✅ 45  | ✅ 16.4 |
| All of the above                                                                                                                  |  ✅ 77  | ✅ 79 |  ✅ 122  | ✅ 26.2 |

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

### Further reading

- [LCP change in Chrome 112](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/speed/metrics_changelog/2023_04_lcp.md) | Chromium
- [Quick BPP check](https://www.phpied.com/quick-bpp-image-entropy-check/) | Stoyan Stefanov
- [LCP Quick Check](https://webperf-snippets.net/CoreWebVitals/LCP.md) | Identify your LCP element
