LCP video candidate
Chrome 77+ Edge 79+ Firefox 122+ Safari 26.2+ Details
Detects whether the LCP element is a <video> and audits the poster image configuration, the most common source of avoidable LCP delay when video is the hero element.
When a <video> element has a poster attribute, the browser treats the poster image as the LCP candidate. The browser cannot discover the poster URL until the parser reaches the <video> tag, so without a <link rel="preload"> the poster is fetched late, increasing LCP.
What this snippet checks
| Check | Scope |
|---|---|
LCP element is a <video> | Last LCP entry |
Missing poster attribute | LCP video |
No <link rel="preload" as="image"> for the poster | LCP video poster |
Preload link without fetchpriority="high" | LCP video poster preload |
| Poster using a legacy format (not AVIF / WebP / JXL) | LCP video poster |
renderTime is zero (cross-origin poster, TAO header missing) | LCP video poster |
| LCP is the first video frame, not a poster | LCP video |
| LCP candidate rendered more than 10 s after the load | Last LCP entry |
When a <video> drives LCP, the critical path for the poster image mirrors that of an LCP <img>:
HTML parsing → <video> tag discovered → poster URL extracted → poster fetch starts → poster decoded → LCP paintedA <link rel="preload"> in <head> shortcuts this chain, allowing the browser to start fetching the poster before the parser reaches the <video> tag:
<!-- 1. Preload the poster as early as possible -->
<link rel="preload" as="image" href="poster.avif" fetchpriority="high" />
<!-- 2. Reference the same URL in the video element -->
<video
autoplay
muted
playsinline
loop
poster="poster.avif"
width="1280"
height="720"
>
<source src="hero.av1.webm" type="video/webm; codecs=av01.0.04M.08" />
<source src="hero.mp4" type="video/mp4" />
</video>The
posterURL in the<link>and the<video>must be identical, including query strings, so the browser can match them and avoid a duplicate fetch.
Snippet
Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.
// LCP Video Candidate
// 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-Video-Candidate", status: "unsupported", error: "largest-contentful-paint entries not supported in this browser" };
}
// Chrome only exposes largest-contentful-paint entries through a PerformanceObserver, so
// performance.getEntriesByType() returns [] here. Collect via a buffered observer.
const collectBuffered = (type, timeoutMs = 100) =>
new Promise((resolve) => {
const entries = [];
try {
const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
obs.observe({ type, buffered: true });
setTimeout(() => { obs.disconnect(); resolve(entries); }, timeoutMs);
} catch {
resolve(entries);
}
});
const lcpEntries = await collectBuffered("largest-contentful-paint");
if (lcpEntries.length === 0) {
console.warn(
"⚠️ No LCP entries found. Run this snippet before interacting with the page, or reload and run it immediately.",
);
return { script: "LCP-Video-Candidate", status: "error", error: "No LCP entries found" };
}
const lcp = lcpEntries[lcpEntries.length - 1];
const element = lcp.element;
function valueToRating(ms) {
return ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";
}
const RATING = {
good: { icon: "🟢", label: "Good (≤ 2.5 s)" },
"needs-improvement": { icon: "🟡", label: "Needs Improvement (≤ 4 s)" },
poor: { icon: "🔴", label: "Poor (> 4 s)" },
};
function detectFormat(url) {
if (!url) return "unknown";
const path = url.toLowerCase().split("?")[0];
const ext = path.match(/\.(avif|webp|jxl|png|gif|jpg|jpeg|svg)(?:[?#]|$)/);
if (ext) return ext[1] === "jpeg" ? "jpg" : ext[1];
return "unknown";
}
function normalizeUrl(url) {
try {
return new URL(url, location.origin).href;
} catch {
return url;
}
}
console.group("%c🎬 LCP Video Candidate", "font-weight: bold; font-size: 14px;");
console.log("");
const activationStart = (() => {
const nav = performance.getEntriesByType("navigation")[0];
return nav?.activationStart || 0;
})();
const lcpTime = Math.round(Math.max(0, lcp.startTime - activationStart));
// --- LCP is NOT a video ---
// The value of a non-video LCP belongs to the LCP snippet; this one only says what the element is
if (!element || element.tagName !== "VIDEO") {
const tag = element ? element.tagName.toLowerCase() : null;
const message = tag
? `The LCP element is <${tag}>, not a <video>. Run the LCP snippet to measure it, and LCP Subparts to see its phases.`
: "The LCP element is no longer in the DOM, so it cannot be checked for a <video>. Run the LCP snippet to measure it.";
console.log("%cLCP element is not a <video>", "font-weight: bold;");
console.log("");
console.log(` Tag : ${tag ? `<${tag}>` : "(element no longer in DOM)"}`);
if (lcp.url) console.log(` URL : ${lcp.url}`);
if (element) console.log(" Element :", element);
console.log("");
console.log(` ${message}`);
console.groupEnd();
return {
script: "LCP-Video-Candidate",
status: "ok",
details: { isVideo: false, lcpElement: tag, lcpUrl: lcp.url || null },
issues: [{ severity: "info", message }],
};
}
// --- LCP IS a video ---
const posterAttr = element.getAttribute("poster") || "";
const posterUrl = posterAttr ? normalizeUrl(posterAttr) : "";
const lcpUrl = lcp.url ? normalizeUrl(lcp.url) : "";
const videoUrls = [element.currentSrc, element.getAttribute("src"), ...Array.from(element.querySelectorAll("source"), (s) => s.getAttribute("src"))]
.filter(Boolean)
.map(normalizeUrl);
// Chrome considers both the poster image and the first video frame as LCP candidates, and gives
// the entry the URL of the poster or of the video file. The URL tells them apart. A video without
// a poster can only be its first frame, and a URL that matches neither (a redirected poster) is
// taken as the poster.
const lcpSource =
lcpUrl && videoUrls.includes(lcpUrl) ? "first-frame"
: lcpUrl && lcpUrl === posterUrl ? "poster"
: !posterAttr ? "first-frame"
: lcpUrl ? "poster"
: "unknown";
const videoUrl = lcpSource === "first-frame" ? lcpUrl || videoUrls[0] || null : null;
// Chrome keeps reporting LCP candidates until the user interacts with the page, so a page left
// open can report one long after its load, which is not the LCP of the load
const loadEventEnd = performance.getEntriesByType("navigation")[0]?.loadEventEnd || 0;
const secondsAfterLoad = loadEventEnd > 0 ? Math.round((lcp.startTime - loadEventEnd) / 1000) : 0;
const rating = valueToRating(lcpTime);
const posterFormat = detectFormat(posterUrl);
const isModernFormat = ["avif", "webp", "jxl"].includes(posterFormat);
const isCrossOrigin = lcp.renderTime === 0 && lcp.loadTime > 0;
const preloadLinks = Array.from(document.querySelectorAll('link[rel="preload"][as="image"]'));
const posterPreload = preloadLinks.find((link) => {
const href = link.getAttribute("href");
if (!href) return false;
try {
return normalizeUrl(href) === posterUrl || normalizeUrl(href) === lcpUrl;
} catch {
return false;
}
}) ?? null;
const preload = element.getAttribute("preload");
const autoplay = element.hasAttribute("autoplay");
const muted = element.hasAttribute("muted") || element.muted;
const playsinline = element.hasAttribute("playsinline");
const issues = [];
if (secondsAfterLoad > 10) {
issues.push({
s: "warning",
msg: `The LCP candidate rendered ${secondsAfterLoad} s after the page loaded, because the page stayed open without interaction. Reload and run the snippet again to measure the LCP of the load`,
});
}
if (lcpSource === "first-frame") {
issues.push({ s: "info", msg: "LCP is the first video frame: adding a poster gives explicit control over the LCP image" });
}
if (lcpSource === "first-frame" && (!autoplay || !muted)) {
issues.push({ s: "warning", msg: "First-frame LCP requires autoplay + muted for the browser to render it immediately" });
}
if (posterAttr && !posterPreload) {
issues.push({ s: "warning", msg: 'No <link rel="preload" as="image"> for the poster: the browser discovers it late' });
} else if (posterPreload && posterPreload.getAttribute("fetchpriority") !== "high") {
issues.push({ s: "info", msg: 'Preload found but missing fetchpriority="high", so it may be deprioritised' });
}
if (posterAttr && !isModernFormat && posterFormat !== "unknown") {
issues.push({ s: "info", msg: `Poster uses ${posterFormat}; AVIF or WebP would reduce file size and LCP load time` });
}
if (isCrossOrigin) {
issues.push({ s: "info", msg: "renderTime is 0: the resource is cross-origin and the server does not send Timing-Allow-Origin" });
}
if (!autoplay && preload === "none") {
issues.push({ s: "warning", msg: 'preload="none" on a non-autoplay video may delay poster image loading in some browsers' });
}
// Summary
console.log("%c✅ LCP element is a <video>", "color: #22c55e; font-weight: bold;");
console.log("");
// LCP metrics
console.log("%cLCP Metrics", "font-weight: bold;");
console.log(` LCP time : ${lcpTime} ms ${RATING[rating].icon} ${RATING[rating].label}`);
console.log(` LCP source : ${lcpSource === "poster" ? "poster image" : lcpSource === "first-frame" ? "first video frame" : "unknown"}`);
console.log(` Render time : ${lcp.renderTime > 0 ? Math.round(lcp.renderTime) + " ms" : "0 (cross-origin — add Timing-Allow-Origin)"}`);
console.log(` Load time : ${Math.round(lcp.loadTime)} ms`);
console.log(` Size : ${Math.round(lcp.size)} px²`);
console.log(` Poster URL : ${posterUrl || "⚠️ (none)"}`);
if (videoUrl) console.log(` Video URL : ${videoUrl}`);
console.log(` Format : ${posterFormat}`);
// Video element
console.log("");
console.log("%cVideo Element", "font-weight: bold;");
console.log(` poster : ${posterAttr || "⚠️ (not set)"}`);
console.log(` preload : ${preload ?? "(not set)"}`);
console.log(` autoplay : ${autoplay ? "✓" : "—"}`);
console.log(` muted : ${muted ? "✓" : "—"}`);
console.log(` playsinline : ${playsinline ? "✓" : "—"}`);
console.log(" Element :", element);
// Preload link
console.log("");
console.log("%cPreload Link", "font-weight: bold;");
if (posterPreload) {
const fp = posterPreload.getAttribute("fetchpriority");
console.log(` Status : ✅ found`);
console.log(` fetchpriority: ${fp ?? "⚠️ (not set)"}`);
console.log(" Element :", posterPreload);
} else {
console.log(` Status : ⚠️ not found`);
if (posterAttr) {
console.log(
` Recommended : <link rel="preload" as="image" href="${posterAttr}" fetchpriority="high">`,
);
}
}
// Issues
if (issues.length > 0) {
const totalErrors = issues.filter((i) => i.s === "error").length;
const totalWarnings = issues.filter((i) => i.s === "warning").length;
const totalInfos = issues.filter((i) => i.s === "info").length;
console.log("");
console.group(
`%c⚠️ Issues (${totalErrors} errors · ${totalWarnings} warnings · ${totalInfos} info)`,
"color: #ef4444; font-weight: bold;",
);
issues.forEach((issue) => {
const prefix = issue.s === "error" ? "🔴" : issue.s === "warning" ? "⚠️" : "ℹ️";
console.log(`${prefix} ${issue.msg}`);
});
console.groupEnd();
} else {
console.log("");
console.log("%c✅ No issues found.", "color: #22c55e; font-weight: bold;");
}
// Quick reference
console.log("");
console.group("%c📝 Quick Reference", "color: #3b82f6; font-weight: bold;");
console.log("");
console.log("%c ✅ Optimal video LCP setup:", "color: #22c55e;");
const ref = posterAttr || "poster.avif";
console.log(
`%c <link rel="preload" as="image" href="${ref}" fetchpriority="high">\n <video autoplay muted playsinline loop poster="${ref}" width="1280" height="720">\n <source src="hero.av1.webm" type="video/webm; codecs=av01.0.04M.08">\n <source src="hero.mp4" type="video/mp4">\n </video>`,
"font-family: monospace;",
);
console.groupEnd();
console.groupEnd();
return {
script: "LCP-Video-Candidate",
status: "ok",
metric: "LCP",
value: lcpTime,
unit: "ms",
rating,
thresholds: { good: 2500, needsImprovement: 4000 },
details: {
isVideo: true,
lcpSource,
posterUrl: posterUrl || null,
videoUrl,
posterFormat,
posterPreloaded: !!posterPreload,
fetchpriorityOnPreload: posterPreload?.getAttribute("fetchpriority") ?? null,
isCrossOrigin,
videoAttributes: { autoplay, muted, playsinline, preload },
},
issues: issues.map((i) => ({ severity: i.s === "warning" ? "warning" : "info", message: i.msg })),
};
})();
Understanding the results
When LCP is not a <video>
The snippet reports the tag and URL of the LCP element, without an LCP value, and points to the LCP snippet to measure it. The LCP Trail and LCP Subparts snippets are better suited for diagnosing non-video LCP elements.
LCP metrics
| Field | Description |
|---|---|
LCP time | startTime from the last LCP entry, the moment the poster or the first frame was painted to screen |
Source | poster, or first-frame when the first frame of the video is the LCP; the entry has the URL of the poster or of the video file |
Render time | Time the poster or frame was rendered; 0 means the resource is cross-origin without a Timing-Allow-Origin header |
Load time | Time the poster or video finished loading over the network |
Size | Rendered area of the video element in pixels² |
Poster URL | The poster attribute of the video, if any |
Video URL | The video file, when the first frame is the LCP |
Format | Image format detected from the URL extension |
Video element
| Field | Description |
|---|---|
poster | Value of the poster attribute, must match the preload href exactly |
preload | none may delay poster loading in non-autoplay videos; metadata or auto are safer |
autoplay | means the browser starts playback immediately, muted is required |
muted | Required for autoplay to work in all modern browsers |
playsinline | Prevents iOS Safari from switching to native fullscreen |
Preload link
| Status | Meaning |
|---|---|
| found (fetchpriority=high) | Optimal, browser fetches the poster at the highest priority before parsing the <video> tag |
| found (no fetchpriority) | Preload exists but may be deprioritised relative to other high resources |
| not found | Browser cannot start fetching the poster until it parses the <video> tag |
Issues detected
| Issue | Severity | Explanation |
|---|---|---|
No poster attribute | Error | Without a poster, the browser has no still image to use as LCP candidate |
| No preload link for poster | Warning | Late discovery of the poster directly increases LCP |
preload="none" on non-autoplay video | Warning | Some browsers delay poster loading until user interaction |
Preload without fetchpriority="high" | Info | Preload competes with other resources and may not run as early as possible |
| Poster using legacy format | Info | AVIF / WebP reduces poster file size by 30–50 %, directly lowering load time |
renderTime is 0 | Info | Cross-origin poster without Timing-Allow-Origin, accurate LCP timing is unavailable |
renderTime and Timing-Allow-Origin
The renderTime field in an LCP entry is set to 0 when the image is served from a different origin and the server does not include the Timing-Allow-Origin: * response header. In that case, loadTime can be used as an approximation, but it does not include decoding time.
To expose accurate render timing for cross-origin posters, add the following header to the image response:
Timing-Allow-Origin: *When to run this snippet
LCP entries accumulate from page load until the first user interaction (click, key press, or scroll). Run the snippet immediately after the page finishes loading, before interacting with it. After a user interaction, performance.getEntriesByType('largest-contentful-paint') still returns the entries collected up to that point.
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 (opens in a new tab) | web.dev
- Optimizing LCP (opens in a new tab) | web.dev
- LCP for video (opens in a new tab) | web.dev
- Timing-Allow-Origin header (opens in a new tab) | MDN
- PerformanceObserver: largest-contentful-paint (opens in a new tab) | MDN
- Fetch Priority API (opens in a new tab) | web.dev