LCP Video Candidate
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 |
Overview
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
// LCP Video Candidate
// https://webperf-snippets.nucliweb.net
(() => {
const lcpEntries = performance.getEntriesByType("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;
}
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("");
// --- LCP is NOT a video ---
if (!element || element.tagName !== "VIDEO") {
const tag = element ? `<${element.tagName.toLowerCase()}>` : "(element no longer in DOM)";
const rating = valueToRating(lcp.startTime);
console.log("%cLCP element is not a <video>", "font-weight: bold;");
console.log("");
console.log(` LCP time : ${Math.round(lcp.startTime)} ms ${RATING[rating].icon} ${RATING[rating].label}`);
console.log(` Tag : ${tag}`);
if (lcp.url) console.log(` URL : ${lcp.url}`);
if (element) console.log(" Element :", element);
console.groupEnd();
return;
}
// --- LCP IS a video ---
const posterAttr = element.getAttribute("poster") || "";
const posterUrl = posterAttr ? normalizeUrl(posterAttr) : "";
const lcpUrl = lcp.url || "";
const rating = valueToRating(lcp.startTime);
const posterFormat = detectFormat(lcpUrl || 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 (!posterAttr) {
issues.push({ s: "error", msg: 'No poster attribute — the browser has no image to use as LCP candidate' });
}
if (posterAttr && !posterPreload) {
issues.push({ s: "warning", msg: 'No <link rel="preload" as="image"> for the poster — browser discovers it late' });
} else if (posterPreload && posterPreload.getAttribute("fetchpriority") !== "high") {
issues.push({ s: "info", msg: 'Preload found but missing fetchpriority="high" — 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 — poster 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 : ${Math.round(lcp.startTime)} ms ${RATING[rating].icon} ${RATING[rating].label}`);
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 : ${lcpUrl || posterUrl || "⚠️ (none)"}`);
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();
})();Understanding the Results
When LCP is not a <video>
The snippet reports the actual LCP element tag and URL, and exits. The LCP Trail and LCP Sub-Parts 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 was painted to screen |
Render time | Time the poster was rendered; 0 means the image is cross-origin without a Timing-Allow-Origin header |
Load time | Time the poster finished loading over the network |
Size | Rendered area of the video element in pixels² |
Poster URL | URL the browser used (lcp.url), which may differ from the poster attribute if redirected |
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.
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