# Largest contentful paint (LCP)

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

Quick check for [Largest Contentful Paint](https://web.dev/articles/lcp), a Core Web Vital that measures loading performance. LCP marks when the largest content element becomes visible in the viewport.

### LCP rating thresholds

| Rating               | Time   | Meaning                       |
| -------------------- | ------ | ----------------------------- |
| 🟢 Good              | ≤ 2.5s | Fast, content appears quickly |
| 🟡 Needs Improvement | ≤ 4s   | Moderate delay                |
| 🔴 Poor              | > 4s   | Slow, users may abandon       |

> **Note:** **Need to optimize?** Use [LCP Subparts](https://webperf-snippets.net/CoreWebVitals/LCP-Subparts.md) to identify which phase (TTFB, load delay, load time, render delay) is causing the bottleneck.

### Snippet

```js
// LCP Quick 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", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
  }
  const valueToRating = (ms) =>
    ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";

  const RATING = {
    good: { icon: "🟢", color: "#0CCE6A" },
    "needs-improvement": { icon: "🟡", color: "#FFA400" },
    poor: { icon: "🔴", color: "#FF4E42" },
  };

  // @shared uniqueSelector
  // A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
  // otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
  // unique id, or to the root, and stops as soon as it matches one element.
  function uniqueSelector(el) {
    const root = el.getRootNode();
    const matchesOne = (selector) => {
      try {
        return root.querySelectorAll(selector).length === 1;
      } catch {
        return false;
      }
    };
    const parts = [];
    let node = el;
    while (node && node.nodeType === 1) {
      if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
        parts.unshift(`#${window.CSS.escape(node.id)}`);
        break;
      }
      const tag = node.localName;
      let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
      const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
      if (siblings.filter((s) => s.matches(part)).length > 1) {
        part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
      }
      parts.unshift(part);
      if (matchesOne(parts.join(" > "))) break;
      node = node.parentElement;
    }
    return parts.join(" > ");
  }
  // @end-shared uniqueSelector

  const getActivationStart = () => {
    const navEntry = performance.getEntriesByType("navigation")[0];
    return navEntry?.activationStart || 0;
  };

  let lastPrintedTime = -1;

  const printLCP = (entry) => {
    const activationStart = getActivationStart();
    const lcpTime = Math.max(0, entry.startTime - activationStart);
    if (lcpTime === lastPrintedTime) return;
    lastPrintedTime = lcpTime;

    const rating = valueToRating(lcpTime);
    const { icon, color } = RATING[rating];

    console.group(`%cLCP: ${icon} ${(lcpTime / 1000).toFixed(2)}s (${rating})`, `color: ${color}; font-weight: bold; font-size: 14px;`);

    const element = entry.element;
    if (element) {
      console.log("");
      console.log("%cLCP Element:", "font-weight: bold;");

      console.log(`   Element: ${uniqueSelector(element)}`, element);

      const tagName = element.tagName.toLowerCase();
      if (tagName === "img") {
        console.log(`   Type: Image`);
        console.log(`   URL: ${entry.url || element.src}`);
        if (element.naturalWidth) {
          console.log(`   Dimensions: ${element.naturalWidth}×${element.naturalHeight}`);
        }
      } else if (tagName === "video") {
        console.log(`   Type: Video poster`);
        console.log(`   URL: ${entry.url || element.poster}`);
      } else if (window.getComputedStyle(element).backgroundImage !== "none") {
        console.log(`   Type: Background image`);
        console.log(`   URL: ${entry.url}`);
      } else {
        console.log(`   Type: ${tagName === "h1" || tagName === "p" ? "Text block" : tagName}`);
      }

      if (entry.size) {
        console.log(`   Size: ${entry.size.toLocaleString()} px²`);
      }

      element.style.outline = "3px dashed lime";
      element.style.outlineOffset = "2px";
      console.log("");
      console.log("%c✓ Element highlighted with green dashed outline", "color: #22c55e;");
    }

    console.groupEnd();
  };

  const observer = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    if (lastEntry) printLCP(lastEntry);
  });

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

  console.log("%c⏱️ LCP Tracking Active", "font-weight: bold; font-size: 14px;");
  console.log("   LCP may update as larger elements load.");

  // Return for agent — collect via buffered observer (getEntriesByType does not
  // expose largest-contentful-paint entries in Chrome without an active observer).
  // 100ms gives the observer time to process buffered entries on busy pages.
  const lastLcpEntry = 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.at(-1) ?? null); }, 100);
  });
  if (!lastLcpEntry) {
    return { script: "LCP", status: "error", error: "No LCP entries buffered" };
  }

  const lcpActivationStart = getActivationStart();
  const lcpValue = Math.round(Math.max(0, lastLcpEntry.startTime - lcpActivationStart));
  const lcpRating = valueToRating(lcpValue);
  const lcpEl = lastLcpEntry.element;
  let lcpSelector = null;
  let lcpType = null;
  if (lcpEl) {
    lcpSelector = uniqueSelector(lcpEl);
    const tag = lcpEl.tagName.toLowerCase();
    lcpType = tag === "img" ? "Image" : tag === "video" ? "Video poster" :
      window.getComputedStyle(lcpEl).backgroundImage !== "none" ? "Background image" :
      (tag === "h1" || tag === "p" ? "Text block" : tag);
  }
  return {
    script: "LCP",
    status: "ok",
    metric: "LCP",
    value: lcpValue,
    unit: "ms",
    rating: lcpRating,
    thresholds: { good: 2500, needsImprovement: 4000 },
    details: {
      element: lcpSelector,
      elementType: lcpType,
      url: lastLcpEntry.url || null,
      sizePixels: lastLcpEntry.size || null,
    },
  };
})();
```

### Understanding LCP

### What can be an LCP element

- `<img>` elements
- `<image>` inside `<svg>`
- `<video>` poster images
- Elements with `background-image` (CSS)
- Block-level text elements (`<p>`, `<h1>`, etc.)

### LCP updates until

- User interacts (click, scroll, keypress)
- A larger element renders
- Page load completes

### Common causes of slow LCP

| Cause                     | Solution                         |
| ------------------------- | -------------------------------- |
| Slow server response      | Optimize TTFB, use CDN           |
| Render-blocking resources | Defer non-critical CSS/JS        |
| Slow resource load        | Preload LCP image, optimize size |
| Client-side rendering     | Use SSR or prerender             |

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

- [Largest Contentful Paint (LCP)](https://web.dev/articles/lcp) | web.dev
- [Optimize LCP](https://web.dev/articles/optimize-lcp) | web.dev
- [LCP Subparts](https://webperf-snippets.net/CoreWebVitals/LCP-Subparts.md) | Detailed phase breakdown
- [LCP Trail](https://webperf-snippets.net/CoreWebVitals/LCP-Trail.md) | Visualize all LCP candidates during page load
- [FCP](https://webperf-snippets.net/Loading/FCP.md) | First Contentful Paint, the earliest paint signal before LCP
