Skip to Content
Core Web VitalsLCP Subparts

LCP subparts

Chrome 77+ Edge 79+ Firefox 122+ Safari 26.2+ Details

Breaks down Largest Contentful Paint (opens in a new tab) 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 (opens in a new tab).

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

The four phases of LCP, in order: time to first byte, load delay, load time and render delayNavigationstartTTFBServerresponseLoad delayResourcediscoveryLoad timeDownloadresourceRender delayPaintelementLCP✓ complete
The four phases of LCP, in order: time to first byte, load delay, load time and render delayNavigation startTTFBServer responseLoad delayResource discoveryLoad timeDownload resourceRender delayPaint elementLCP ✓ complete
Sub-partWhat it measuresCommon causes
Time to First ByteServer response timeSlow server, no CDN, no caching
Resource Load DelayTime from TTFB until LCP resource starts loadingRender-blocking resources, late discovery
Resource Load TimeTime to download the LCP resourceLarge images, slow connection, no compression
Element Render DelayTime from download complete to paintClient-side rendering, render-blocking JS

Optimization priority

PhaseTargetHow to optimize
TTFB< 800msUse CDN, optimize server, enable caching
Load Delay< 10% of LCPPreload LCP image, remove render-blocking resources
Load Time< 40% of LCPCompress images, use modern formats (WebP, AVIF)
Render Delay< 10% of LCPInline critical CSS, defer non-critical JS
Switch scenarios to see which sub-part is over its target, and select a sub-part to highlight it. Open demo in a new tab ↗

Quick check: Use LCP for a simple LCP value and element identification.

Snippet

Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.

// LCP Subparts Analysis
// https://webperf-snippets.nucliweb.net

(async () => {
  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 },
  ];

  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")
      .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;
      let selector = el.tagName.toLowerCase();
      if (el.id) selector = `#${el.id}`;
      else if (el.className && typeof el.className === "string") {
        const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
        if (classes) selector = `${el.tagName.toLowerCase()}.${classes}`;
      }

      console.log("");
      console.log("%cLCP Element:", "font-weight: bold;");
      console.log(`   ${selector}`, 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")
    .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) {
    const el = lcpEntry.element;
    lcpSelectorSync = el.tagName.toLowerCase();
    if (el.id) lcpSelectorSync = `#${el.id}`;
    else if (el.className && typeof el.className === "string") {
      const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
      if (classes) lcpSelectorSync = `${el.tagName.toLowerCase()}.${classes}`;
    }
  }
  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

ColumnDescription
Sub-partPhase name ( marks the slowest)
TimeDuration 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
TTFBServer response time, CDN, caching headers
Load DelayPreload hints, render-blocking resources, late <img> in HTML
Load TimeImage size, format, compression, CDN
Render DelayJavaScript blocking, CSS parsing, client-side rendering

Optimization decision tree

Optimization decision tree: find the slowest LCP phase, then apply the fixes listed under itIdentify the slowest phaseTTFB > 800 ms?Yes✓Use a CDN✓Enable caching✓Optimize the serverLoad delay > 10%?Yes✓Preload theLCP image✓Remove render-blocking resources✓Inline critical CSSLoad time > 40%?Yes✓Compress images✓Use WebP or AVIF✓Use responsiveimagesRender delay > 10%?Yes✓Defer non-criticalJavaScript✓Avoid client-siderendering✓Set fetchpriorityto high
Optimization decision tree: find the slowest LCP phase, then apply the fixes listed under itIdentify the slowest phaseTTFB > 800 ms?Yes✓Use a CDN✓Enable caching✓Optimize the serverLoad delay > 10%?Yes✓Preload the LCP image✓Remove render-blocking resources✓Inline critical CSSLoad time > 40%?Yes✓Compress images✓Use WebP or AVIF✓Use responsive imagesRender delay > 10%?Yes✓Defer non-critical JavaScript✓Avoid client-side rendering✓Set fetchpriority to high

Browser support

FeatureChromeEdgeFirefoxSafari
LargestContentfulPaint (opens in a new tab) 77 79 122 26.2
PerformanceObserver (opens in a new tab) 52 79 57 11
PerformanceNavigationTiming (opens in a new tab) 57 12 58 15
PerformanceResourceTiming (opens in a new tab) 29 12 35 11
All of the above 77 79 122 26.2

Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.

Further reading

Last updated on
MIT 2026 © Joan León | @nucliweb