Largest contentful paint (LCP)
Chrome 77+ Edge 79+ Firefox 122+ Safari 26.2+ Details
Quick check for Largest Contentful Paint (opens in a new tab), a Core Web Vital that measures loading performance. LCP marks when the largest content element becomes visible in the viewport.
LCP rating thresholds
| Rating | Time | Meaning |
|---|---|---|
| Good | ≤ 2.5s | Fast, content appears quickly |
| Needs Improvement | ≤ 4s | Moderate delay |
| Poor | > 4s | Slow, users may abandon |
Need to optimize? Use LCP Subparts to identify which phase (TTFB, load delay, load time, render delay) is causing the bottleneck.
Snippet
Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.
// LCP Quick 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", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
}
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" },
};
// @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 getActivationStart = () => {
const navEntry = performance.getEntriesByType("navigation")[0];
return navEntry?.activationStart || 0;
};
let lastPrintedTime = -1;
const printLCP = (entry) => {
const activationStart = getActivationStart();
const lcpTime = Math.max(0, entry.startTime - activationStart);
if (lcpTime === lastPrintedTime) return;
lastPrintedTime = lcpTime;
const rating = valueToRating(lcpTime);
const { icon, color } = RATING[rating];
console.group(`%cLCP: ${icon} ${(lcpTime / 1000).toFixed(2)}s (${rating})`, `color: ${color}; font-weight: bold; font-size: 14px;`);
const element = entry.element;
if (element) {
console.log("");
console.log("%cLCP Element:", "font-weight: bold;");
console.log(` Element: ${uniqueSelector(element)}`, element);
const tagName = element.tagName.toLowerCase();
if (tagName === "img") {
console.log(` Type: Image`);
console.log(` URL: ${entry.url || element.src}`);
if (element.naturalWidth) {
console.log(` Dimensions: ${element.naturalWidth}×${element.naturalHeight}`);
}
} else if (tagName === "video") {
console.log(` Type: Video poster`);
console.log(` URL: ${entry.url || element.poster}`);
} else if (window.getComputedStyle(element).backgroundImage !== "none") {
console.log(` Type: Background image`);
console.log(` URL: ${entry.url}`);
} else {
console.log(` Type: ${tagName === "h1" || tagName === "p" ? "Text block" : tagName}`);
}
if (entry.size) {
console.log(` Size: ${entry.size.toLocaleString()} px²`);
}
element.style.outline = "3px dashed lime";
element.style.outlineOffset = "2px";
console.log("");
console.log("%c✓ Element highlighted with green dashed outline", "color: #22c55e;");
}
console.groupEnd();
};
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
if (lastEntry) printLCP(lastEntry);
});
observer.observe({ type: "largest-contentful-paint", buffered: true });
console.log("%c⏱️ LCP Tracking Active", "font-weight: bold; font-size: 14px;");
console.log(" LCP may update as larger elements load.");
// Return for agent — collect via buffered observer (getEntriesByType does not
// expose largest-contentful-paint entries in Chrome without an active observer).
// 100ms gives the observer time to process buffered entries on busy pages.
const lastLcpEntry = 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 (!lastLcpEntry) {
return { script: "LCP", status: "error", error: "No LCP entries buffered" };
}
const lcpActivationStart = getActivationStart();
const lcpValue = Math.round(Math.max(0, lastLcpEntry.startTime - lcpActivationStart));
const lcpRating = valueToRating(lcpValue);
const lcpEl = lastLcpEntry.element;
let lcpSelector = null;
let lcpType = null;
if (lcpEl) {
lcpSelector = uniqueSelector(lcpEl);
const tag = lcpEl.tagName.toLowerCase();
lcpType = tag === "img" ? "Image" : tag === "video" ? "Video poster" :
window.getComputedStyle(lcpEl).backgroundImage !== "none" ? "Background image" :
(tag === "h1" || tag === "p" ? "Text block" : tag);
}
return {
script: "LCP",
status: "ok",
metric: "LCP",
value: lcpValue,
unit: "ms",
rating: lcpRating,
thresholds: { good: 2500, needsImprovement: 4000 },
details: {
element: lcpSelector,
elementType: lcpType,
url: lastLcpEntry.url || null,
sizePixels: lastLcpEntry.size || null,
},
};
})();
Understanding LCP
What can be an LCP element
<img>elements<image>inside<svg><video>poster images- Elements with
background-image(CSS) - Block-level text elements (
<p>,<h1>, etc.)
When LCP stops updating
The browser reports a new LCP candidate each time a larger element renders, also after the load event. It stops reporting new candidates as soon as the user interacts with the page (a tap, scroll, or keypress), so the LCP is the last candidate reported before that first interaction.
Common causes of slow LCP
| Cause | Solution |
|---|---|
| Slow server response | Optimize TTFB, use CDN |
| Render-blocking resources | Defer non-critical CSS/JS |
| Slow resource load | Preload LCP image, optimize size |
| Client-side rendering | Use SSR or prerender |
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 |
| All of the above | 77 | 79 | 122 | 26.2 |
Source: MDN browser compatibility data (opens in a new tab), version 8.1.6.
Further reading
- Largest Contentful Paint (LCP) (opens in a new tab) | web.dev
- Optimize LCP (opens in a new tab) | web.dev
- LCP Subparts | Detailed phase breakdown
- LCP Trail | Visualize all LCP candidates during page load
- FCP | First Contentful Paint, the earliest paint signal before LCP