Skip to Content

LCP trail

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

Tracks every LCP candidate (opens in a new tab) element during page load and highlights each one with a distinct pastel-colored dashed outline, so you can see the full trail from first candidate to final LCP.

Each time the browser promotes a larger element as the new LCP, the snippet assigns the next color in the palette and highlights it. All previous candidates remain visible on the page for easy comparison.

Need timing breakdowns? Use LCP Subparts to identify which phase (TTFB, load delay, load time, render delay) is causing the bottleneck.

See how candidates are promoted

Replay four loads, or move the slider through one, to see when each element becomes a candidate, which ones never do, and how a user input ends the trail:

Pick a scenario, replay the load and drag the slider to inspect any moment. Open demo in a new tab ↗

Snippet

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

// LCP Trail
// Tracks all LCP candidate elements during page load
// 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-Trail", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
  }
  const PALETTE = [
    { color: "#EF4444", name: "Red" },
    { color: "#F97316", name: "Orange" },
    { color: "#22C55E", name: "Green" },
    { color: "#3B82F6", name: "Blue" },
    { color: "#A855F7", name: "Purple" },
    { color: "#EC4899", name: "Pink" },
  ];

  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 getActivationStart = () => {
    const navEntry = performance.getEntriesByType("navigation")[0];
    return navEntry?.activationStart || 0;
  };

  const getSelector = (element) => {
    if (element.id) return `#${element.id}`;
    if (element.className && typeof element.className === "string") {
      const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
      if (classes) return `${element.tagName.toLowerCase()}.${classes}`;
    }
    return element.tagName.toLowerCase();
  };

  const getElementInfo = (element, entry) => {
    const tag = element.tagName.toLowerCase();
    if (tag === "img") return { type: "Image", url: entry.url || element.src };
    if (tag === "video") return { type: "Video poster", url: entry.url || element.poster };
    if (window.getComputedStyle(element).backgroundImage !== "none") return { type: "Background image", url: entry.url };
    return { type: tag === "h1" || tag === "p" ? "Text block" : tag };
  };

  const candidates = [];

  const logTrail = () => {
    const current = candidates[candidates.length - 1];
    if (!current) return;

    const rating = valueToRating(current.time);
    const { icon, color: ratingColor } = RATING[rating];

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

    // Current LCP element attribution
    console.log("");
    console.log("%cLCP Element:", "font-weight: bold;");
    console.log(`   Element: ${current.selector}`, current.element);

    const { type, url } = getElementInfo(current.element, current.entry);
    console.log(`   Type: ${type}`);
    if (url) console.log(`   URL: ${url}`);
    if (current.element.naturalWidth) {
      console.log(
        `   Dimensions: ${current.element.naturalWidth}×${current.element.naturalHeight}`
      );
    }
    if (current.entry.size) {
      console.log(`   Size: ${current.entry.size.toLocaleString()} px²`);
    }

    // Trail legend
    console.log("");
    console.log("%cCandidates Trail:", "font-weight: bold;");
    candidates.forEach(({ index, selector, color, name, time, element }) => {
      const isCurrent = index === candidates.length;
      console.log(
        `%c  ● ${index}. ${selector}`,
        `color: ${color}; font-weight: ${isCurrent ? "bold" : "normal"};`,
        `| ${(time / 1000).toFixed(2)}s — ${name}${isCurrent ? " ← LCP" : ""}`,
        element
      );
    });

    console.log("");
    console.log(
      "%c✓ Each candidate highlighted with a colored dashed outline",
      "color: #22c55e;"
    );
    console.groupEnd();
  };

  const observer = new PerformanceObserver((list) => {
    const activationStart = getActivationStart();
    const seen = new Set(candidates.map((c) => c.element));

    for (const entry of list.getEntries()) {
      const { element } = entry;
      if (!element || seen.has(element)) continue;

      const { color, name } = PALETTE[candidates.length % PALETTE.length];

      element.style.outline = `3px dashed ${color}`;
      element.style.outlineOffset = "2px";

      candidates.push({
        index: candidates.length + 1,
        element,
        selector: getSelector(element),
        color,
        name,
        time: Math.max(0, entry.startTime - activationStart),
        entry,
      });

      seen.add(element);
    }

    logTrail();
  });

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

  console.log("%c⏱️ LCP Trail Active", "font-weight: bold; font-size: 14px;");
  console.log("   Highlights all LCP candidate elements with distinct colors.");

  // Return for agent (buffered entries)
  // Chrome only exposes largest-contentful-paint entries through a PerformanceObserver,
  // so performance.getEntriesByType() returns [] here. Collect via a buffered observer.
  const trailEntries = 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);
  });
  if (trailEntries.length === 0) {
    return { script: "LCP-Trail", status: "error", error: "No LCP entries yet" };
  }
  const trailActivationStart = getActivationStart();
  const seenEls = new Set();
  const syncCandidates = [];
  for (const entry of trailEntries) {
    const el = entry.element;
    if (!el || seenEls.has(el)) continue;
    seenEls.add(el);
    const selector = getSelector(el);
    const time = Math.round(Math.max(0, entry.startTime - trailActivationStart));
    const { type, url } = getElementInfo(el, entry);
    syncCandidates.push({
      index: syncCandidates.length + 1,
      selector,
      time,
      elementType: type,
      ...(url ? {
        url: (() => {
          try {
            const u = new URL(url);
            return u.hostname !== location.hostname
              ? `${u.hostname}/…/${u.pathname.split("/").pop()?.split("?")[0]}`
              : u.pathname.split("/").pop()?.split("?")[0] || url;
          } catch { return url; }
        })(),
      } : {}),
    });
  }
  if (syncCandidates.length === 0) {
    return { script: "LCP-Trail", status: "error", error: "No LCP elements in DOM" };
  }
  const lastCandidate = syncCandidates.at(-1);
  const trailValue = lastCandidate.time;
  const trailRating = valueToRating(trailValue);
  return {
    script: "LCP-Trail",
    status: "ok",
    metric: "LCP",
    value: trailValue,
    unit: "ms",
    rating: trailRating,
    thresholds: { good: 2500, needsImprovement: 4000 },
    details: {
      candidateCount: syncCandidates.length,
      finalElement: lastCandidate.selector,
      candidates: syncCandidates,
    },
  };
})();

How it works

The snippet observes largest-contentful-paint entries with buffered: true, which captures all candidates since navigation started. Each time the browser promotes a new element as the current LCP:

  1. The element receives a dashed outline in the next palette color
  2. The console logs are updated with attribution for the current LCP
  3. The Candidates Trail legend shows every candidate in order, with its color, time, and a clickable element reference

Multiple candidates appear when

  • An image loads after a text element that was initially larger in the viewport
  • A hero image replaces a heading as the largest content
  • Lazy-loaded or late-loading resources become the largest visible element

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