# LCP trail

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

Tracks every [LCP candidate](https://web.dev/articles/lcp#what-elements-are-considered) 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.

> **Note:** **Need timing breakdowns?** 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.

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

Interactive demo: [Interactive model of a page load in four scenarios, showing when each element is promoted as an LCP candidate with its own colored outline, why a later or smaller element is not, and how the first user input stops new candidates, with the candidates trail and a time slider](https://webperf-snippets.net/demos/lcp-candidates.html). Pick a scenario, replay the load and drag the slider to inspect any moment.

### Snippet

```js
// LCP Trail
// Tracks all LCP candidate elements during page load
// 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-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;
  };

  // @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 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: uniqueSelector(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 = uniqueSelector(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

| 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  |
| 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
- [LCP element candidates](https://web.dev/articles/lcp#what-elements-are-considered) | web.dev
- [LCP](https://webperf-snippets.net/CoreWebVitals/LCP.md) | Quick LCP check
- [LCP Subparts](https://webperf-snippets.net/CoreWebVitals/LCP-Subparts.md) | Detailed phase breakdown
- [FCP](https://webperf-snippets.net/Loading/FCP.md) | First Contentful Paint, the earliest paint signal before LCP
