LCP subparts
Chrome 77+ Edge 79+ Firefox 122+ Safari 26.2+ Details
Breaks down Largest Contentful Paint (opens in a new tab) 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 (opens in a new tab).
Why this matters
LCP is a critical Core Web Vital that measures when the largest content element becomes visible. By breaking it into sub-parts, you can pinpoint exactly where delays occur and apply targeted optimizations instead of guessing.
The four phases of LCP
| Sub-part | What it measures | Common causes |
|---|---|---|
| Time to First Byte | Server response time | Slow server, no CDN, no caching |
| Resource Load Delay | Time from TTFB until LCP resource starts loading | Render-blocking resources, late discovery |
| Resource Load Time | Time to download the LCP resource | Slow server for the resource, large images, slow connection, no compression |
| Element Render Delay | Time from download complete to paint | Client-side rendering, render-blocking JS |
Optimization priority
| Phase | Target | How to optimize |
|---|---|---|
| TTFB | < 800ms | Use CDN, optimize server, enable caching |
| Load Delay | < 10% of LCP | Preload LCP image, remove render-blocking resources |
| Load Time | < 40% of LCP | Compress images, use modern formats (WebP, AVIF) |
| Render Delay | < 10% of LCP | Inline critical CSS, defer non-critical JS |
Quick check: Use LCP for a simple LCP value and element identification.
Snippet
Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.
// 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
// The Resource Load Time splits into the wait for the server, until the first byte of the response,
// and the download of the body: a slow server and a heavy image need different fixes. Another
// origin without Timing-Allow-Origin reports responseStart as 0, and then there is no split.
const splitLoadTime = (resEntry, requestStart, responseEnd, activationStart) => {
if (!resEntry?.responseStart) return null;
const firstByte = Math.min(Math.max(requestStart, resEntry.responseStart - activationStart), responseEnd);
return { waiting: firstByte - requestStart, download: responseEnd - firstByte };
};
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 loadSplit = splitLoadTime(lcpResEntry, lcpRequestStart, lcpResponseEnd, 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 === "resourceLoadTime" && loadSplit) {
console.log(` ${formatMs(loadSplit.waiting)} waiting for the server, ${formatMs(loadSplit.download)} downloading the resource`);
}
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: <link rel=\"preload\" as=\"image\" href=\"...\">");
console.log(" → Remove render-blocking resources");
console.log(" → Inline critical CSS");
} else if (slowest.key === "resourceLoadTime" && loadSplit && loadSplit.waiting > loadSplit.download) {
console.log(" → Reduce the server response time of the LCP resource");
console.log(" → Serve it from a CDN, with Cache-Control headers");
console.log(" → Check that it is cached, not generated on each request");
} 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 loadSplitSync = splitLoadTime(lcpResEntrySync, lcpRequestStartSync, lcpResponseEndSync, activationStartSync);
const waitingVal = loadSplitSync ? Math.min(loadTimeVal, Math.round(loadSplitSync.waiting)) : null;
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,
...(loadSplitSync ? { waiting: waitingVal, download: loadTimeVal - waitingVal } : {}),
},
elementRenderDelay: { value: renderDelayVal, percent: Math.round((renderDelayVal / totalSync) * 100), overTarget: (renderDelayVal / totalSync) * 100 > 10 },
},
slowestPhase: slowestPhaseSync,
},
};
})();
Understanding the results
Sub-parts table
| Column | Description |
|---|---|
| Sub-part | Phase name ( marks the slowest) |
| Time | Duration in milliseconds |
| % | Percentage of total LCP |
| Status | OK or Over target |
Waiting or downloading
The snippet splits Resource Load Time with the Resource Timing entry of the LCP resource: the time waiting for the server, until the first byte of the response, and the time downloading the body. They need different fixes: a long wait points to the server or the cache of the resource, a long download to its size. When Resource Load Time is the slowest phase, the console shows the split and recommends fixes for the larger part; the result always carries both values, in details.subParts.resourceLoadTime.waiting and .download.
A resource from another origin hides that timing unless it sends a Timing-Allow-Origin header, and then the snippet reports Resource Load Time without the split.
Visual bar
Shows time distribution across the four phases:
- █ TTFB (server response)
- ▓ Load Delay (discovery to request)
- ▒ Load Time (download)
- ░ Render Delay (paint)
Performance timeline
The snippet adds one User Timing measure per sub-part (“Time to First Byte”, “Resource Load Delay”, etc.) when it runs. A reload discards them, so read them in the same page view:
- In the console, with
performance.getEntriesByType("measure") - In DevTools → Performance: start a recording, run the snippet, and stop the recording. The measures appear in the Timings track, at the time of each sub-part
Optimization checklist
| If slowest is… | Check these |
|---|---|
| TTFB | Server response time, CDN, caching headers |
| Load Delay | Preload hints, render-blocking resources, late <img> in HTML |
| Load Time | Long wait: server response time of the resource, CDN, caching. Long download: image size, format, compression |
| Render Delay | JavaScript blocking, CSS parsing, client-side rendering |
Optimization decision tree
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 |
| PerformanceResourceTiming (opens in a new tab) | 29 | 12 | 35 | 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
- Optimize LCP (opens in a new tab) | web.dev
- LCP breakdown (opens in a new tab) | web.dev
- Preload critical assets (opens in a new tab) | web.dev
- LCP Quick Check | Simple LCP measurement