# LCP video candidate

> Source: <https://webperf-snippets.net/CoreWebVitals/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         |
| 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 painted
```

A `<link rel="preload">` in `<head>` shortcuts this chain, allowing the browser to start fetching the poster before the parser reaches the `<video>` tag:

```html
<!-- 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 `poster` URL in the `<link>` and the `<video>` must be identical, including query strings, so the browser can match them and avoid a duplicate fetch.

### Snippet

```js
// 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](https://webperf-snippets.net/CoreWebVitals/LCP.md) snippet to measure it. The [LCP Trail](https://webperf-snippets.net/CoreWebVitals/LCP-Trail.md) and [LCP Subparts](https://webperf-snippets.net/CoreWebVitals/LCP-Subparts.md) 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](https://developer.mozilla.org/docs/Web/API/LargestContentfulPaint)           |  ✅ 77  | ✅ 79 |  ✅ 122  | ✅ 26.2 |
| [PerformanceObserver](https://developer.mozilla.org/docs/Web/API/PerformanceObserver)                 |  ✅ 52  | ✅ 79 |   ✅ 57  |  ✅ 11  |
| [PerformanceNavigationTiming](https://developer.mozilla.org/docs/Web/API/PerformanceNavigationTiming) |  ✅ 57  | ✅ 12 |   ✅ 58  |  ✅ 15  |
| All of the above                                                                                      |  ✅ 77  | ✅ 79 |  ✅ 122  | ✅ 26.2 |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

### Further reading

- [Largest Contentful Paint](https://web.dev/articles/lcp) | web.dev
- [Optimizing LCP](https://web.dev/articles/optimize-lcp) | web.dev
- [LCP for video](https://web.dev/articles/lcp#lcp_for_video) | web.dev
- [Timing-Allow-Origin header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Timing-Allow-Origin) | MDN
- [PerformanceObserver: largest-contentful-paint](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint) | MDN
- [Fetch Priority API](https://web.dev/articles/fetch-priority) | web.dev
