# LCP subparts

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

Breaks down [Largest Contentful Paint](https://web.dev/articles/lcp) into its four phases to identify optimization opportunities. Understanding which phase is slowest helps you focus your optimization efforts where they'll have the most impact. Based on the [Web Vitals Chrome Extension](https://github.com/GoogleChrome/web-vitals-extension).

### Why this matters

LCP is a critical Core Web Vital that measures when the largest content element becomes visible. By breaking it into sub-parts, you can pinpoint exactly where delays occur and apply targeted optimizations instead of guessing.

### The four phases of LCP

| Sub-part                 | What it measures                                 | Common causes                                 |
| ------------------------ | ------------------------------------------------ | --------------------------------------------- |
| **Time to First Byte**   | Server response time                             | Slow server, no CDN, no caching               |
| **Resource Load Delay**  | Time from TTFB until LCP resource starts loading | Render-blocking resources, late discovery     |
| **Resource Load Time**   | Time to download the LCP resource                | Large images, slow connection, no compression |
| **Element Render Delay** | Time from download complete to paint             | Client-side rendering, render-blocking JS     |

### Optimization priority

| Phase        | Target       | How to optimize                                     |
| ------------ | ------------ | --------------------------------------------------- |
| TTFB         | < 800ms      | Use CDN, optimize server, enable caching            |
| Load Delay   | < 10% of LCP | Preload LCP image, remove render-blocking resources |
| Load Time    | < 40% of LCP | Compress images, use modern formats (WebP, AVIF)    |
| Render Delay | < 10% of LCP | Inline critical CSS, defer non-critical JS          |

Interactive demo: [Timeline of the four LCP sub-parts for five scenarios: optimized, late discovery, heavy image, client-side rendering and slow server](https://webperf-snippets.net/demos/lcp-subparts.html). Switch scenarios to see which sub-part is over its target, and select a sub-part to highlight it.

> **Note:** **Quick check:** Use [LCP](https://webperf-snippets.net/CoreWebVitals/LCP.md) for a simple LCP value and element identification.

### Snippet

```js
// LCP Subparts Analysis
// https://webperf-snippets.net

(async () => {
  // @shared isOwnRequest
  // True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
  // did not make it, so no snippet counts it.
  function isOwnRequest(entry) {
    return entry.name.endsWith("#webperf-snippets");
  }
  // @end-shared isOwnRequest

  if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
    console.warn("⚠️ largest-contentful-paint entries are not supported in this browser.");
    return { script: "LCP-Subparts", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
  }
  const formatMs = (ms) => `${Math.round(ms)}ms`;
  const formatPercent = (value, total) => `${Math.round((value / total) * 100)}%`;

  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" },
  };

  const SUB_PARTS = [
    { name: "Time to First Byte", key: "ttfb", target: 800 },
    { name: "Resource Load Delay", key: "resourceLoadDelay", targetPercent: 10 },
    { name: "Resource Load Time", key: "resourceLoadTime", targetPercent: 40 },
    { name: "Element Render Delay", key: "elementRenderDelay", targetPercent: 10 },
  ];

  // @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 getNavigationEntry = () => {
    const navEntry = performance.getEntriesByType("navigation")[0];
    if (navEntry?.responseStart > 0 && navEntry.responseStart < performance.now()) {
      return navEntry;
    }
    return null;
  };

  const observer = new PerformanceObserver((list) => {
    const lcpEntry = list.getEntries().at(-1);
    if (!lcpEntry) return;

    const navEntry = getNavigationEntry();
    if (!navEntry) return;

    const lcpResEntry = performance
      .getEntriesByType("resource").filter((e) => !isOwnRequest(e))
      .find((e) => e.name === lcpEntry.url);

    const activationStart = navEntry.activationStart || 0;

    // Calculate sub-parts
    const ttfb = Math.max(0, navEntry.responseStart - activationStart);

    const lcpRequestStart = Math.max(
      ttfb,
      lcpResEntry
        ? (lcpResEntry.requestStart || lcpResEntry.startTime) - activationStart
        : 0
    );

    const lcpResponseEnd = Math.max(
      lcpRequestStart,
      lcpResEntry ? lcpResEntry.responseEnd - activationStart : 0
    );

    const lcpRenderTime = Math.max(
      lcpResponseEnd,
      lcpEntry.startTime - activationStart
    );

    const subPartValues = {
      ttfb: ttfb,
      resourceLoadDelay: lcpRequestStart - ttfb,
      resourceLoadTime: lcpResponseEnd - lcpRequestStart,
      elementRenderDelay: lcpRenderTime - lcpResponseEnd,
    };

    // LCP Rating
    const rating = valueToRating(lcpRenderTime);
    const { icon, color } = RATING[rating];

    console.group(
      `%cLCP: ${icon} ${formatMs(lcpRenderTime)} (${rating})`,
      `color: ${color}; font-weight: bold; font-size: 14px;`
    );

    // Element info
    if (lcpEntry.element) {
      const el = lcpEntry.element;
      console.log("");
      console.log("%cLCP Element:", "font-weight: bold;");
      console.log(`   ${uniqueSelector(el)}`, el);
      if (lcpEntry.url) {
        const shortUrl = (() => {
          try {
            const u = new URL(lcpEntry.url);
            return u.hostname !== location.hostname
              ? `${u.hostname}/…/${u.pathname.split("/").pop()?.split("?")[0]}`
              : u.pathname.split("/").pop()?.split("?")[0] || lcpEntry.url;
          } catch { return lcpEntry.url; }
        })();
        console.log(`   URL: ${shortUrl}`);
      }

      // Highlight
      el.style.outline = "3px dashed lime";
      el.style.outlineOffset = "2px";
    }

    // Sub-parts table
    console.log("");
    console.log("%cSub-Parts Breakdown:", "font-weight: bold;");

    // Find the slowest phase
    const phases = SUB_PARTS.map((part) => ({
      ...part,
      value: subPartValues[part.key],
      percent: (subPartValues[part.key] / lcpRenderTime) * 100,
    }));

    const slowest = phases.reduce((a, b) => (a.value > b.value ? a : b));

    const tableData = phases.map((part) => {
      const isSlowest = part.key === slowest.key;
      const isOverTarget = part.target
        ? part.value > part.target
        : part.percent > part.targetPercent;

      return {
        "Sub-part": isSlowest ? `⚠️ ${part.name}` : part.name,
        Time: formatMs(part.value),
        "%": formatPercent(part.value, lcpRenderTime),
        Status: isOverTarget ? "🔴 Over target" : "✅ OK",
      };
    });

    console.table(tableData);

    // Visual bar
    const barWidth = 40;
    const bars = phases.map((p) => {
      const width = Math.max(1, Math.round((p.value / lcpRenderTime) * barWidth));
      return { key: p.key, bar: width };
    });

    const ttfbBar = "█".repeat(bars[0].bar);
    const delayBar = "▓".repeat(bars[1].bar);
    const loadBar = "▒".repeat(bars[2].bar);
    const renderBar = "░".repeat(bars[3].bar);

    console.log("");
    console.log(`   ${ttfbBar}${delayBar}${loadBar}${renderBar}`);
    console.log("   █ TTFB  ▓ Load Delay  ▒ Load Time  ░ Render Delay");

    // Recommendations based on slowest phase
    console.log("");
    console.log("%c💡 Optimization Focus:", "font-weight: bold; color: #3b82f6;");
    console.log(`   Slowest phase: ${slowest.name} (${formatPercent(slowest.value, lcpRenderTime)})`);

    if (slowest.key === "ttfb") {
      console.log("   → Use a CDN to reduce latency");
      console.log("   → Enable server-side caching");
      console.log("   → Optimize server response time");
    } else if (slowest.key === "resourceLoadDelay") {
      console.log("   → Preload the LCP image: <link rel=\"preload\" as=\"image\" href=\"...\">");
      console.log("   → Remove render-blocking resources");
      console.log("   → Inline critical CSS");
    } else if (slowest.key === "resourceLoadTime") {
      console.log("   → Compress and resize the LCP image");
      console.log("   → Use modern formats (WebP, AVIF)");
      console.log("   → Use a CDN for faster delivery");
    } else if (slowest.key === "elementRenderDelay") {
      console.log("   → Reduce render-blocking JavaScript");
      console.log("   → Avoid client-side rendering for LCP element");
      console.log("   → Use fetchpriority=\"high\" on LCP image");
    }

    // Performance entries for DevTools
    SUB_PARTS.forEach((part) => performance.clearMeasures(part.name));

    phases.forEach((part) => {
      const startTimes = {
        ttfb: 0,
        resourceLoadDelay: ttfb,
        resourceLoadTime: lcpRequestStart,
        elementRenderDelay: lcpResponseEnd,
      };
      performance.measure(part.name, {
        start: startTimes[part.key],
        end: startTimes[part.key] + part.value,
      });
    });

    console.log("");
    console.log("%c📊 Measures added to Performance timeline", "color: #666;");
    console.log("   Open DevTools → Performance → reload to see waterfall");

    console.groupEnd();
  });

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

  console.log("%c📊 LCP Subparts Analysis Active", "font-weight: bold; font-size: 14px;");
  console.log("   Waiting for LCP...");

  // Return for agent — collect via buffered observer (getEntriesByType does not
  // expose largest-contentful-paint entries in Chrome without an active observer).
  const lcpEntry = 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 (!lcpEntry) {
    return { script: "LCP-Subparts", status: "error", error: "No LCP entries buffered" };
  }
  const navEntrySync = getNavigationEntry();
  if (!navEntrySync) {
    return { script: "LCP-Subparts", status: "error", error: "No navigation entry" };
  }
  const lcpResEntrySync = performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e))
    .find((e) => e.name === lcpEntry.url);
  const activationStartSync = navEntrySync.activationStart || 0;
  const ttfbSync = Math.max(0, navEntrySync.responseStart - activationStartSync);
  const lcpRequestStartSync = Math.max(ttfbSync,
    lcpResEntrySync ? (lcpResEntrySync.requestStart || lcpResEntrySync.startTime) - activationStartSync : 0
  );
  const lcpResponseEndSync = Math.max(lcpRequestStartSync,
    lcpResEntrySync ? lcpResEntrySync.responseEnd - activationStartSync : 0
  );
  const lcpRenderTimeSync = Math.max(lcpResponseEndSync, lcpEntry.startTime - activationStartSync);
  const totalSync = Math.round(lcpRenderTimeSync);
  const ratingSync = valueToRating(totalSync);
  const ttfbVal = Math.round(ttfbSync);
  const loadDelayVal = Math.round(lcpRequestStartSync - ttfbSync);
  const loadTimeVal = Math.round(lcpResponseEndSync - lcpRequestStartSync);
  const renderDelayVal = Math.round(lcpRenderTimeSync - lcpResponseEndSync);
  const subPartsForRank = [
    { key: "ttfb", value: ttfbVal },
    { key: "resourceLoadDelay", value: loadDelayVal },
    { key: "resourceLoadTime", value: loadTimeVal },
    { key: "elementRenderDelay", value: renderDelayVal },
  ];
  const slowestPhaseSync = subPartsForRank.reduce((a, b) => a.value > b.value ? a : b).key;
  let lcpSelectorSync = null;
  if (lcpEntry.element) {
    lcpSelectorSync = uniqueSelector(lcpEntry.element);
  }
  const shortUrlSync = lcpEntry.url
    ? (() => {
        try {
          const u = new URL(lcpEntry.url);
          return u.hostname !== location.hostname
            ? `${u.hostname}/…/${u.pathname.split("/").pop()?.split("?")[0]}`
            : u.pathname.split("/").pop()?.split("?")[0] || lcpEntry.url;
        } catch { return lcpEntry.url; }
      })()
    : null;
  return {
    script: "LCP-Subparts",
    status: "ok",
    metric: "LCP",
    value: totalSync,
    unit: "ms",
    rating: ratingSync,
    thresholds: { good: 2500, needsImprovement: 4000 },
    details: {
      element: lcpSelectorSync,
      url: shortUrlSync,
      subParts: {
        ttfb: { value: ttfbVal, percent: Math.round((ttfbVal / totalSync) * 100), overTarget: ttfbVal > 800 },
        resourceLoadDelay: { value: loadDelayVal, percent: Math.round((loadDelayVal / totalSync) * 100), overTarget: (loadDelayVal / totalSync) * 100 > 10 },
        resourceLoadTime: { value: loadTimeVal, percent: Math.round((loadTimeVal / totalSync) * 100), overTarget: (loadTimeVal / totalSync) * 100 > 40 },
        elementRenderDelay: { value: renderDelayVal, percent: Math.round((renderDelayVal / totalSync) * 100), overTarget: (renderDelayVal / totalSync) * 100 > 10 },
      },
      slowestPhase: slowestPhaseSync,
    },
  };
})();
```

### Understanding the results

### Sub-parts table

| Column   | Description                       |
| -------- | --------------------------------- |
| Sub-part | Phase name (⚠️ marks the slowest) |
| Time     | Duration in milliseconds          |
| %        | Percentage of total LCP           |
| Status   | ✅ OK or 🔴 Over target            |

### Visual bar

Shows time distribution across the four phases:

- █ TTFB (server response)
- ▓ Load Delay (discovery to request)
- ▒ Load Time (download)
- ░ Render Delay (paint)

### Performance timeline

The snippet adds measures to the Performance API. To see them:

1. Open DevTools → Performance tab
2. Reload the page
3. Look for "Time to First Byte", "Resource Load Delay", etc. in the timeline

### Optimization checklist

| If slowest is... | Check these                                                    |
| ---------------- | -------------------------------------------------------------- |
| **TTFB**         | Server response time, CDN, caching headers                     |
| **Load Delay**   | Preload hints, render-blocking resources, late `<img>` in HTML |
| **Load Time**    | Image size, format, compression, CDN                           |
| **Render Delay** | JavaScript blocking, CSS parsing, client-side rendering        |

### Optimization decision tree

### 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  |
| [PerformanceNavigationTiming](https://developer.mozilla.org/docs/Web/API/PerformanceNavigationTiming) |  ✅ 57  | ✅ 12 |   ✅ 58  |  ✅ 15  |
| [PerformanceResourceTiming](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming)     |  ✅ 29  | ✅ 12 |   ✅ 35  |  ✅ 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

- [Optimize LCP](https://web.dev/articles/optimize-lcp) | web.dev
- [LCP breakdown](https://web.dev/articles/optimize-lcp#lcp-breakdown) | web.dev
- [Preload critical assets](https://web.dev/articles/preload-critical-assets) | web.dev
- [LCP Quick Check](https://webperf-snippets.net/CoreWebVitals/LCP.md) | Simple LCP measurement
