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:
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:
- The element receives a dashed outline in the next palette color
- The console logs are updated with attribution for the current LCP
- 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 (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
- Largest Contentful Paint (LCP) (opens in a new tab) | web.dev
- LCP element candidates (opens in a new tab) | web.dev
- LCP | Quick LCP check
- LCP Subparts | Detailed phase breakdown
- FCP | First Contentful Paint, the earliest paint signal before LCP