# WebPerf Snippets — Full Reference
Tested JavaScript snippets that measure and debug web performance. Your AI agent runs them for you, or you run them yourself in DevTools.
Source: https://webperf-snippets.net
Version: 2.0.1
---
# Core Web Vitals
## Cumulative layout shift (CLS)
URL: https://webperf-snippets.net/CoreWebVitals/CLS
Quick check for Cumulative Layout Shift, a Core Web Vital that measures visual stability. CLS tracks how much the page layout shifts unexpectedly during its lifetime, providing a single score that represents the largest burst of unexpected layout shifts.
```js
// CLS Quick Check
// https://webperf-snippets.net
(async () => {
if (!PerformanceObserver.supportedEntryTypes?.includes("layout-shift")) {
console.warn("⚠️ layout-shift entries are not supported in this browser.");
return { script: "CLS", status: "unsupported", error: "layout-shift entries not supported in this browser" };
}
// CLS is the largest session window: shifts less than 1s apart, in a window of at most 5s.
let cls = 0;
let sessionValue = 0;
let sessionFirst = null;
let sessionLast = null;
const addShift = (entry) => {
if (entry.hadRecentInput) return;
if (
sessionLast &&
entry.startTime - sessionLast.startTime < 1000 &&
entry.startTime - sessionFirst.startTime < 5000
) {
sessionValue += entry.value;
} else {
sessionValue = entry.value;
sessionFirst = entry;
}
sessionLast = entry;
cls = Math.max(cls, sessionValue);
};
const valueToRating = (score) =>
score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";
const RATING = {
good: { icon: "🟢", color: "#0CCE6A" },
"needs-improvement": { icon: "🟡", color: "#FFA400" },
poor: { icon: "🔴", color: "#FF4E42" },
};
const logCLS = () => {
const rating = valueToRating(cls);
const { icon, color } = RATING[rating];
console.log(
`%cCLS: ${icon} ${cls.toFixed(4)} (${rating})`,
`color: ${color}; font-weight: bold; font-size: 14px;`
);
};
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) addShift(entry);
logCLS();
});
observer.observe({ type: "layout-shift", buffered: true });
// Update on visibility change (final CLS)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
observer.takeRecords();
console.log("%c📊 Final CLS (on page hide):", "font-weight: bold;");
logCLS();
}
});
// Expose function for manual check
window.getCLS = () => {
logCLS();
const rating = valueToRating(cls);
return {
script: "CLS",
status: "ok",
metric: "CLS",
value: Math.round(cls * 10000) / 10000,
unit: "score",
rating,
thresholds: { good: 0.1, needsImprovement: 0.25 },
};
};
console.log(
" Call %cgetCLS()%c anytime to check current value.",
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
""
);
// Return for agent. Chrome only exposes layout-shift entries through a PerformanceObserver,
// so wait for the buffered observer above to deliver them.
await new Promise((resolve) => setTimeout(resolve, 100));
const clsSync = cls;
const clsRating = valueToRating(clsSync);
return {
script: "CLS",
status: "ok",
metric: "CLS",
value: Math.round(clsSync * 10000) / 10000,
unit: "score",
rating: clsRating,
thresholds: { good: 0.1, needsImprovement: 0.25 },
message: "CLS tracking active. Call getCLS() for updated value after page interactions.",
getDataFn: "getCLS",
};
})();
```
---
## Interaction to next paint (INP)
URL: https://webperf-snippets.net/CoreWebVitals/INP
Tracks Interaction to Next Paint, a Core Web Vital that measures responsiveness. INP evaluates how quickly a page responds to user interactions throughout the entire page visit, replacing First Input Delay (FID) as a Core Web Vital in March 2024.
```js
// INP (Interaction to Next Paint) Tracking
// https://webperf-snippets.net
(() => {
if (!PerformanceObserver.supportedEntryTypes?.includes("event")) {
console.warn("⚠️ event entries are not supported in this browser.");
return { script: "INP", status: "unsupported", error: "event entries not supported in this browser" };
}
let interactions = [];
let inpValue = 0;
let inpEntry = null;
const valueToRating = (ms) =>
ms <= 200 ? "good" : ms <= 500 ? "needs-improvement" : "poor";
const RATING = {
good: { icon: "🟢", color: "#0CCE6A" },
"needs-improvement": { icon: "🟡", color: "#FFA400" },
poor: { icon: "🔴", color: "#FF4E42" },
};
const formatMs = (ms) => `${Math.round(ms)}ms`;
// @shared createInteractionTracker
// Groups Event Timing entries by interactionId across observer callbacks, since the entries of one
// interaction (pointerdown, pointerup, click; keydown, keyup) can arrive in separate batches. Each
// interaction is measured over its entries painted in the frame of its longest one: Chrome rounds
// durations to 8 ms, and an event painted in an earlier frame, such as the keydown of a key held
// down, has its own timing. It is reported under the event whose handler ran longest, or under the
// click or keydown when no handler took a millisecond more than theirs.
function createInteractionTracker() {
const byId = new Map();
const processing = (e) => e.processingEnd - e.processingStart;
const measure = (id, entries) => {
const longest = entries.reduce((a, b) => (b.duration > a.duration ? b : a));
const paint = longest.startTime + longest.duration;
const frame = entries.filter((e) => Math.abs(e.startTime + e.duration - paint) <= 8);
const startTime = Math.min(...frame.map((e) => e.startTime));
const processingStart = Math.min(...frame.map((e) => e.processingStart));
const processingEnd = Math.max(...frame.map((e) => e.processingEnd));
const busiest = frame.reduce((a, b) => (processing(b) > processing(a) ? b : a));
const primary = frame.find((e) => e.name === "click" || e.name === "keydown");
const reported = primary && processing(busiest) - processing(primary) < 1 ? primary : busiest;
return {
interactionId: id,
name: reported.name,
target: reported.target ?? frame.find((e) => e.target)?.target ?? null,
startTime,
duration: longest.duration,
phases: {
inputDelay: processingStart - startTime,
processingTime: processingEnd - processingStart,
presentationDelay: Math.max(0, startTime + longest.duration - processingEnd),
},
};
};
return {
// Adds the entries of one observer callback and returns the interactions they changed
add(entries) {
const changed = new Set();
for (const e of entries) {
if (!e.interactionId) continue;
const record = byId.get(e.interactionId) ?? { entries: [] };
record.entries.push({
name: e.name,
target: e.target,
startTime: e.startTime,
duration: e.duration,
processingStart: e.processingStart,
processingEnd: e.processingEnd,
});
byId.set(e.interactionId, record);
changed.add(e.interactionId);
}
return [...changed].map((id) => {
const record = byId.get(id);
record.summary = measure(id, record.entries);
return record.summary;
});
},
list() {
return [...byId.values()].map((record) => record.summary);
},
};
}
// @end-shared createInteractionTracker
// @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
// Calculate INP (98th percentile of all interactions)
const calculateINP = () => {
if (interactions.length === 0) return { value: 0, entry: null };
// Sort by duration
const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
// Skip one interaction for every 50. Use the browser's total interaction count when
// available: `interactions` only holds those above the duration threshold.
const totalInteractions = performance.interactionCount || interactions.length;
const index = Math.min(sorted.length - 1, Math.floor(totalInteractions / 50));
return {
value: sorted[index].duration,
entry: sorted[index],
};
};
// Format interaction name
const getInteractionName = (entry) => {
const target = entry.target;
if (!target) return entry.name;
let selector = target.tagName.toLowerCase();
if (target.id) selector += `#${target.id}`;
else if (target.className && typeof target.className === "string") {
const classes = target.className.trim().split(/\s+/).slice(0, 2).join(".");
if (classes) selector += `.${classes}`;
}
return `${entry.name} → ${selector}`;
};
// One record per interaction (interactionId), with its phases measured over all its entries
const tracker = createInteractionTracker();
const addEntries = (entries) => {
tracker.add(entries);
interactions = tracker.list().map((i) => ({ ...i, formattedName: getInteractionName(i) }));
const result = calculateINP();
inpValue = result.value;
inpEntry = result.entry;
};
// Observer for interactions
const observer = new PerformanceObserver((list) => addEntries(list.getEntries()));
// Observe event timing
observer.observe({
type: "event",
buffered: true,
durationThreshold: 16, // Only interactions > 16ms (1 frame)
});
// Log INP summary
const logINP = () => {
const rating = valueToRating(inpValue);
const { icon, color } = RATING[rating];
console.group(
`%cINP: ${icon} ${formatMs(inpValue)} (${rating})`,
`color: ${color}; font-weight: bold; font-size: 14px;`
);
console.log("");
console.log(`%c📊 Analysis:`, "font-weight: bold;");
console.log(` Total interactions tracked: ${interactions.length}`);
console.log(` INP (98th percentile): ${formatMs(inpValue)}`);
if (inpEntry) {
console.log("");
console.log(`%c🎯 Worst Interaction (INP):`, `font-weight: bold; color: ${color};`);
console.log(` Event: ${inpEntry.formattedName}`);
console.log(` Duration: ${formatMs(inpEntry.duration)}`);
// Element attribution
if (inpEntry.target) {
console.log(` Target Element:`, inpEntry.target);
// Get element path for better context
const getElementPath = (el) => {
if (!el) return "";
const parts = [];
let current = el;
while (current && current !== document.body && parts.length < 5) {
let selector = current.tagName.toLowerCase();
if (current.id) selector += `#${current.id}`;
else if (current.className && typeof current.className === "string") {
const classes = current.className.trim().split(/\s+/).slice(0, 2).join(".");
if (classes) selector += `.${classes}`;
}
parts.unshift(selector);
current = current.parentElement;
}
return parts.join(" > ");
};
const path = getElementPath(inpEntry.target);
if (path) {
console.log(` Element Path: ${path}`);
}
}
// Phase breakdown
const phases = inpEntry.phases;
if (phases.inputDelay > 0) {
console.log("");
console.log(`%c⏱️ Phase Breakdown:`, "font-weight: bold;");
console.log(` Input Delay: ${formatMs(phases.inputDelay)}`);
console.log(` Processing Time: ${formatMs(phases.processingTime)}`);
console.log(` Presentation Delay: ${formatMs(phases.presentationDelay)}`);
// Visual bar
const total = inpEntry.duration;
const barWidth = 40;
const inputBar = "▓".repeat(Math.round((phases.inputDelay / total) * barWidth));
const processBar = "█".repeat(Math.round((phases.processingTime / total) * barWidth));
const presentBar = "░".repeat(Math.round((phases.presentationDelay / total) * barWidth));
console.log(` ${inputBar}${processBar}${presentBar}`);
console.log(" ▓ Input █ Processing ░ Presentation");
}
// Recommendations based on phases
if (inpValue > 200 && phases.inputDelay > 0) {
console.log("");
console.log("%c💡 Recommendations:", "color: #3b82f6; font-weight: bold;");
if (phases.inputDelay > 100) {
console.log(" • High input delay - Break up long tasks before interaction");
}
if (phases.processingTime > 200) {
console.log(" • Long processing time - Optimize event handlers");
console.log(" • Consider debouncing, use requestIdleCallback for non-critical work");
}
if (phases.presentationDelay > 100) {
console.log(" • High presentation delay - Reduce render complexity");
console.log(" • Batch DOM updates, use content-visibility");
}
}
}
// Slow interactions breakdown
const slowInteractions = interactions
.filter((i) => i.duration > 200)
.sort((a, b) => b.duration - a.duration)
.slice(0, 10);
if (slowInteractions.length > 0) {
console.log("");
console.log(`%c🐌 Slow Interactions (> 200ms):`, "color: #ef4444; font-weight: bold;");
console.table(
slowInteractions.map((i) => ({
Event: i.formattedName,
"Duration (ms)": Math.round(i.duration),
"Start Time (ms)": Math.round(i.startTime),
}))
);
// Show element attribution for top 3
console.log("");
console.log(`%c🎯 Element Attribution (top 3):`, "font-weight: bold;");
slowInteractions.slice(0, 3).forEach((interaction, idx) => {
console.log(` ${idx + 1}. ${interaction.formattedName} (${Math.round(interaction.duration)}ms)`);
if (interaction.target) {
console.log(` Element:`, interaction.target);
} else {
console.log(` Element: (no target available)`);
}
});
}
// Interaction types breakdown
const byType = {};
interactions.forEach((i) => {
const type = i.name;
if (!byType[type]) {
byType[type] = { count: 0, totalDuration: 0, maxDuration: 0 };
}
byType[type].count++;
byType[type].totalDuration += i.duration;
byType[type].maxDuration = Math.max(byType[type].maxDuration, i.duration);
});
if (Object.keys(byType).length > 0) {
console.log("");
console.log(`%c📋 By Interaction Type:`, "font-weight: bold;");
console.table(
Object.entries(byType).map(([type, stats]) => ({
Type: type,
Count: stats.count,
"Avg (ms)": Math.round(stats.totalDuration / stats.count),
"Max (ms)": Math.round(stats.maxDuration),
}))
);
}
// General recommendations if no phases available
if (inpValue > 200 && (!inpEntry || !inpEntry.phases || inpEntry.phases.inputDelay === 0)) {
console.log("");
console.log("%c💡 Recommendations:", "color: #3b82f6; font-weight: bold;");
console.log(" • Break up long tasks using scheduler.yield() or setTimeout");
console.log(" • Optimize event handlers - reduce computation time");
console.log(" • Consider debouncing for frequent events");
console.log(" • Move heavy work to Web Workers");
console.log(" • Use requestIdleCallback for non-critical work");
console.log("");
console.log(" Run getINPDetails() for full interaction list");
console.log(" Use Long Animation Frames snippet to identify blocking scripts");
}
console.groupEnd();
};
// Expose function to check INP anytime
window.getINP = () => {
const result = calculateINP();
inpValue = result.value;
inpEntry = result.entry;
logINP();
const rating = valueToRating(inpValue);
const details = { totalInteractions: interactions.length };
if (inpEntry) {
details.worstEvent = inpEntry.formattedName;
details.phases = {
inputDelay: Math.round(inpEntry.phases.inputDelay),
processingTime: Math.round(inpEntry.phases.processingTime),
presentationDelay: Math.round(inpEntry.phases.presentationDelay),
};
}
if (interactions.length === 0) {
return {
script: "INP",
status: "error",
error: "No interactions recorded yet. Interact with the page and call getINP() again.",
getDataFn: "getINP",
details,
};
}
return {
script: "INP",
status: "ok",
metric: "INP",
value: Math.round(inpValue),
unit: "ms",
rating,
thresholds: { good: 200, needsImprovement: 500 },
details,
};
};
// Expose function to get all interactions
window.getINPDetails = () => {
console.group("%c📊 All Interactions Detail", "font-weight: bold; font-size: 14px;");
if (interactions.length === 0) {
console.log(" No interactions recorded yet.");
console.groupEnd();
return {
script: "INP",
status: "error",
error: "No interactions recorded yet. Interact with the page and call getINPDetails() again.",
count: 0,
items: [],
};
}
const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
console.log("");
console.log("%cInteraction Summary:", "font-weight: bold;");
console.table(
sorted.map((i, idx) => ({
"#": idx + 1,
Event: i.formattedName,
"Duration (ms)": Math.round(i.duration),
"Start (ms)": Math.round(i.startTime),
"Input Delay": Math.round(i.phases.inputDelay),
Processing: Math.round(i.phases.processingTime),
Presentation: Math.round(i.phases.presentationDelay),
}))
);
// Show element attribution for all interactions
console.log("");
console.log("%c🎯 Element Attribution:", "font-weight: bold;");
const maxToShow = Math.min(sorted.length, 15); // Show up to 15
sorted.slice(0, maxToShow).forEach((interaction, idx) => {
const phases = interaction.phases;
const hasPhases = phases.inputDelay > 0;
console.group(
`${idx + 1}. ${interaction.formattedName} - ${Math.round(interaction.duration)}ms`
);
if (interaction.target) {
console.log("Element:", interaction.target);
// Get element path for better identification
const getPath = (el) => {
if (!el) return "";
const parts = [];
let current = el;
while (current && current !== document.body && parts.length < 5) {
let selector = current.tagName.toLowerCase();
if (current.id) selector += `#${current.id}`;
else if (current.className && typeof current.className === "string") {
const classes = current.className.trim().split(/\s+/).slice(0, 2).join(".");
if (classes) selector += `.${classes}`;
}
parts.unshift(selector);
current = current.parentElement;
}
return parts.join(" > ");
};
const path = getPath(interaction.target);
if (path) {
console.log("Path:", path);
}
} else {
console.log("Element: (no target available)");
}
if (hasPhases) {
console.log(
`Phases: Input ${Math.round(phases.inputDelay)}ms | ` +
`Processing ${Math.round(phases.processingTime)}ms | ` +
`Presentation ${Math.round(phases.presentationDelay)}ms`
);
}
console.groupEnd();
});
if (sorted.length > maxToShow) {
console.log(` ... and ${sorted.length - maxToShow} more interactions`);
}
console.groupEnd();
// The 15 slowest, as plain data: the target is a selector, not the element
return {
script: "INP",
status: "ok",
count: sorted.length,
items: sorted.slice(0, maxToShow).map((i) => ({
formattedName: i.formattedName,
eventType: i.name,
target: i.target ? uniqueSelector(i.target) : null,
duration: Math.round(i.duration),
startTime: Math.round(i.startTime),
phases: {
inputDelay: Math.round(i.phases.inputDelay),
processingTime: Math.round(i.phases.processingTime),
presentationDelay: Math.round(i.phases.presentationDelay),
},
})),
};
};
// Log on page hide (final INP)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
addEntries(observer.takeRecords());
console.log("%c📊 Final INP (on page hide):", "font-weight: bold;");
logINP();
}
});
console.log("%c⚡ INP Tracking Active", "font-weight: bold; font-size: 14px;");
console.log(" Interactions with duration > 16ms will be tracked.");
console.log(
" Call %cgetINP()%c to see current INP value.",
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
""
);
console.log(
" Call %cgetINPDetails()%c for full interaction list.",
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
""
);
return {
script: "INP",
status: "tracking",
message: "INP tracking active. Interact with the page then call getINP() for results.",
getDataFn: "getINP",
};
})();
```
---
## LCP image entropy
URL: https://webperf-snippets.net/CoreWebVitals/LCP-Image-Entropy
Checks if images qualify as LCP candidates based on their entropy (bits per pixel). Since Chrome 112, low-entropy images are ignored for LCP measurement.
```js
// LCP Image Entropy Check
// 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-Image-Entropy", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
}
// @shared formatBytes
function formatBytes(bytes) {
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
}
// @end-shared formatBytes
const LCP_THRESHOLD = 0.05; // Chrome's threshold for low-entropy
// Chrome divides the encoded size by the area the image is displayed at, in CSS pixels (not
// device pixels), and an upscaled image by its natural area instead, so stretching an image never
// lowers its bits per pixel. Chrome uses the part inside the viewport; this uses the whole box.
// https://w3c.github.io/largest-contentful-paint/ (steps 8.9 to 8.13) and Chromium's
// LargestContentfulPaintCalculator::ComputeEffectiveVisualSize
function displayedSize(img) {
const rect = img.getBoundingClientRect();
const style = getComputedStyle(img);
const px = (prop) => parseFloat(style[prop]) || 0;
return {
width: Math.max(0, rect.width - px("borderLeftWidth") - px("borderRightWidth") - px("paddingLeft") - px("paddingRight")),
height: Math.max(0, rect.height - px("borderTopWidth") - px("borderBottomWidth") - px("paddingTop") - px("paddingBottom")),
};
}
function bitsPerPixel(img, fileSize) {
const displayed = displayedSize(img);
const pixels = Math.min(displayed.width * displayed.height, img.naturalWidth * img.naturalHeight);
return { displayed, bpp: pixels > 0 ? (fileSize * 8) / pixels : 0 };
}
// Get current LCP element
let lcpElement = null;
let lcpUrl = null;
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
if (lastEntry) {
lcpElement = lastEntry.element;
lcpUrl = lastEntry.url;
}
});
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
// Wait a tick to ensure LCP is captured (for human console output)
setTimeout(() => {
lcpObserver.disconnect();
// Get all images
const images = [...document.images]
.filter((img) => {
const src = img.currentSrc || img.src;
return src && !src.startsWith("data:image");
})
.map((img) => {
const src = img.currentSrc || img.src;
const resource = performance.getEntriesByName(src)[0];
const fileSize = resource?.encodedBodySize || 0;
const { displayed, bpp } = bitsPerPixel(img, fileSize);
const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
const isLCP = lcpElement === img || lcpUrl === src;
return {
element: img,
src,
shortSrc: src.split("/").pop()?.split("?")[0] || src,
width: img.naturalWidth,
height: img.naturalHeight,
displayedWidth: Math.round(displayed.width),
displayedHeight: Math.round(displayed.height),
fileSize,
bpp,
isLowEntropy,
isLCP,
lcpEligible: !isLowEntropy && bpp > 0,
};
})
.filter((img) => img.bpp > 0); // Only images with measurable BPP
console.group("%c🖼️ Image Entropy Analysis", "font-weight: bold; font-size: 14px;");
if (images.length === 0) {
console.log(" No images with measurable entropy found.");
console.log(" (Data URLs and cross-origin images without CORS are excluded)");
console.groupEnd();
return;
}
// Summary
const lowEntropy = images.filter((img) => img.isLowEntropy);
const normalEntropy = images.filter((img) => !img.isLowEntropy);
const lcpImage = images.find((img) => img.isLCP);
console.log("");
console.log("%cSummary:", "font-weight: bold;");
console.log(` Total images analyzed: ${images.length}`);
console.log(` 🟢 Normal entropy (LCP eligible): ${normalEntropy.length}`);
console.log(` 🔴 Low entropy (LCP ineligible): ${lowEntropy.length}`);
if (lcpImage) {
const icon = lcpImage.isLowEntropy ? "⚠️" : "✅";
console.log("");
console.log(`%c${icon} Current LCP image:`, "font-weight: bold;");
console.log(` ${lcpImage.shortSrc}`);
console.log(` BPP: ${lcpImage.bpp.toFixed(4)} ${lcpImage.isLowEntropy ? "(LOW - may be skipped!)" : "(OK)"}`);
}
// Table
console.log("");
console.log("%cAll Images:", "font-weight: bold;");
const tableData = images
.sort((a, b) => b.bpp - a.bpp)
.map((img) => ({
Image: img.shortSrc.length > 30 ? "..." + img.shortSrc.slice(-27) : img.shortSrc,
Dimensions: `${img.width}×${img.height}`,
Displayed: `${img.displayedWidth}×${img.displayedHeight}`,
Size: formatBytes(img.fileSize),
BPP: img.bpp.toFixed(4),
Entropy: img.isLowEntropy ? "🔴 Low" : "🟢 Normal",
"LCP Eligible": img.lcpEligible ? "✅" : "❌",
"Is LCP": img.isLCP ? "👈" : "",
}));
console.table(tableData);
// Warnings
if (lowEntropy.length > 0) {
console.log("");
console.log("%c⚠️ Low Entropy Images:", "font-weight: bold; color: #f59e0b;");
console.log(" These images will NOT be considered for LCP in Chrome 112+:");
lowEntropy.forEach((img) => {
console.log(` • ${img.shortSrc} (BPP: ${img.bpp.toFixed(4)})`, img.element);
});
}
if (lcpImage && lcpImage.isLowEntropy) {
console.log("");
console.log("%c🚨 Warning:", "font-weight: bold; color: #ef4444;");
console.log(" Your LCP image has low entropy and may be skipped by Chrome!");
console.log(" Chrome will use the next largest element instead.");
console.log("");
console.log("%c💡 Solutions:", "font-weight: bold; color: #3b82f6;");
console.log(" • Replace placeholder with actual content image");
console.log(" • Use a text element as LCP instead");
console.log(" • Ensure hero image loads with sufficient detail");
}
// Elements for inspection
console.log("");
console.log("%c🔎 Inspect elements:", "font-weight: bold;");
images.forEach((img, i) => {
const icon = img.isLowEntropy ? "🔴" : "🟢";
const lcpMark = img.isLCP ? " 👈 LCP" : "";
console.log(` ${i + 1}. ${icon} ${img.shortSrc}${lcpMark}`, img.element);
});
console.groupEnd();
}, 100);
// Return for agent (buffered entries + DOM)
// Chrome only exposes largest-contentful-paint entries through a PerformanceObserver,
// so performance.getEntriesByType() returns [] here. Collect via a buffered observer.
const lcpEntriesSync = 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);
});
const lcpEntrySync = lcpEntriesSync.at(-1);
const lcpElementSync = lcpEntrySync?.element ?? null;
const lcpUrlSync = lcpEntrySync?.url ?? null;
const imagesSync = [...document.images]
.filter((img) => { const src = img.currentSrc || img.src; return src && !src.startsWith("data:image"); })
.map((img) => {
const src = img.currentSrc || img.src;
const resource = performance.getEntriesByName(src)[0];
const fileSize = resource?.encodedBodySize || 0;
const { displayed, bpp } = bitsPerPixel(img, fileSize);
const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
const isLCP = lcpElementSync === img || lcpUrlSync === src;
return {
url: src.split("/").pop()?.split("?")[0] || src,
width: img.naturalWidth,
height: img.naturalHeight,
displayedWidth: Math.round(displayed.width),
displayedHeight: Math.round(displayed.height),
fileSizeBytes: fileSize,
bpp: Math.round(bpp * 10000) / 10000,
isLowEntropy,
lcpEligible: !isLowEntropy && bpp > 0,
isLCP,
};
})
.filter((img) => img.bpp > 0);
const lowEntropyCount = imagesSync.filter((img) => img.isLowEntropy).length;
const lcpImageSync = imagesSync.find((img) => img.isLCP);
if (lcpElementSync) {
lcpElementSync.style.outline = "3px dashed lime";
lcpElementSync.style.outlineOffset = "2px";
}
const issuesSync = [];
if (lowEntropyCount > 0) {
issuesSync.push({ severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
}
if (lcpImageSync?.isLowEntropy) {
issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
}
// Entropy applies to the LCP only when it is an
whose file size can be read; for text, or an
// image this snippet cannot read, it says so. The images of the page are still listed.
const lcpTag = lcpElementSync ? lcpElementSync.tagName.toLowerCase() : null;
if (lcpTag && !lcpImageSync) {
issuesSync.push({
severity: "info",
message: lcpTag === "img"
? "The LCP
has no file size to read, as with a data: URL or a cross-origin image without Timing-Allow-Origin, so its entropy cannot be computed."
: lcpUrlSync
? `The LCP is the image of a <${lcpTag}>, such as a CSS background or a video poster, and this snippet reads
elements only. Run the LCP snippet to measure it.`
: `The LCP element is <${lcpTag}>, text and not an image, so its entropy does not apply. Run the LCP snippet to measure it.`,
});
}
// The list keeps the 50 most relevant images (the LCP image, then low entropy, then lowest bits per pixel); count and details cover all of them
const MAX_ITEMS = 50;
const rankedImages = [...imagesSync].sort(
(a, b) => Number(b.isLCP) - Number(a.isLCP) || Number(b.isLowEntropy) - Number(a.isLowEntropy) || a.bpp - b.bpp
);
return {
script: "LCP-Image-Entropy",
status: "ok",
count: imagesSync.length,
details: {
totalImages: imagesSync.length,
lowEntropyCount,
lcpElement: lcpTag,
lcpImageEligible: lcpImageSync ? !lcpImageSync.isLowEntropy : null,
lcpImage: lcpImageSync ? { url: lcpImageSync.url, bpp: lcpImageSync.bpp, isLowEntropy: lcpImageSync.isLowEntropy } : null,
},
items: rankedImages.slice(0, MAX_ITEMS),
issues: issuesSync,
};
})();
```
---
## LCP subparts
URL: https://webperf-snippets.net/CoreWebVitals/LCP-Subparts
Breaks down Largest Contentful Paint 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.
```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: ");
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(
' Read them with performance.getEntriesByType("measure"), or run the snippet during a DevTools Performance recording to see them in the Timings track'
);
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,
},
};
})();
```
---
## LCP trail
URL: https://webperf-snippets.net/CoreWebVitals/LCP-Trail
Tracks every LCP candidate 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.
```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,
},
};
})();
```
---
## LCP video candidate
URL: https://webperf-snippets.net/CoreWebVitals/LCP-Video-Candidate
Detects whether the LCP element is a