DEV Community

Mason K
Mason K

Posted on

Fix video LCP: poster, preload, and a Playwright check that proves which element Chrome measures

TL;DR

Since Chrome 116 a <video> can be your Largest Contentful Paint element. We'll build a two-page test, watch Chrome report the LCP entry with and without a poster, then add the preload and loading rules that keep the fix from backfiring.

๐Ÿ“ฆ Code: github.com/USER/video-lcp-check (replace before publishing)

Versions used: node 22, playwright with Chromium 153.0.8010.12, ffmpeg 6.1.1. #webdev #tutorial

Before Chrome 116 (August 2023), videos without a poster did not contribute to LCP, per DebugBear. Now the first frame of the video, or the poster if you have one, is what gets timed.

1. ๐ŸŽฅ Make a test video and a poster

# make-assets.sh
ffmpeg -y -f lavfi -i "mandelbrot=size=640x360:rate=15" -t 4 \
  -c:v libx264 -crf 28 -pix_fmt yuv420p -movflags +faststart -an fast.mp4

# grab one frame as the poster, then a WebP version
ffmpeg -y -i fast.mp4 -frames:v 1 -vf scale=640:360 poster.jpg
ffmpeg -y -i fast.mp4 -frames:v 1 -vf scale=640:360 -c:v libwebp poster.webp
ls -l poster.*
Enter fullscreen mode Exit fullscreen mode
-rw-r--r-- 1 root root 20546 poster.jpg
-rw-r--r-- 1 root root 13002 poster.webp
Enter fullscreen mode Exit fullscreen mode

The WebP is smaller here, which matches DebugBear's advice to prefer a modern format. Always check that your poster is actually smaller than the video it stands in for.

2. ๐Ÿ“„ Two pages: no poster, and with poster

<!-- nop.html -->
<!doctype html><meta charset="utf-8">
<body style="margin:0"><h1 style="font-size:12px">hi</h1>
<video src="/fast.mp4" width="640" height="360" muted playsinline preload="metadata"></video>
Enter fullscreen mode Exit fullscreen mode
<!-- pos.html: identical, plus the poster attribute -->
<!doctype html><meta charset="utf-8">
<body style="margin:0"><h1 style="font-size:12px">hi</h1>
<video src="/fast.mp4" width="640" height="360" muted playsinline preload="metadata"
       poster="/poster.jpg"></video>
Enter fullscreen mode Exit fullscreen mode

Add this to the bottom of both. It records every LCP candidate Chrome reports:

<script>
window.__lcp = [];
new PerformanceObserver(list => {
  for (const e of list.getEntries()) {
    window.__lcp.push({ t: Math.round(e.startTime), tag: e.element && e.element.tagName,
                        url: e.url, size: e.size });
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });
</script>
Enter fullscreen mode Exit fullscreen mode

3. ๐Ÿ› ๏ธ Serve with a server that supports ranges

Chrome seeks video with byte ranges. Python's built-in http.server ignores them, so I used a 25-line Node server (we'll build the same thing in the range-requests post). Any static server that returns Accept-Ranges: bytes works. Set the right Content-Type per file, or your HTML will come back as a video.

โš ๏ธ Note: my first run printed undefined for both pages, because my server sent Content-Type: video/mp4 for the HTML file. Chrome never ran the script as a page.

4. โœ… Read the LCP entries with Playwright

// check-lcp.js
const { chromium } = require('playwright');
(async () => {
  const b = await chromium.launch();
  console.log('chromium', b.version());
  for (const p of ['nop', 'pos']) {
    const pg = await b.newPage();
    await pg.goto(`http://127.0.0.1:8801/${p}.html`);
    await pg.waitForTimeout(1500);
    console.log(p, JSON.stringify(await pg.evaluate(() => window.__lcp)));
  }
  await b.close();
})();
Enter fullscreen mode Exit fullscreen mode
$ node check-lcp.js
chromium 153.0.8010.12
nop [{"t":132,"tag":"H1","url":"","size":154},{"t":184,"tag":"VIDEO","url":"http://127.0.0.1:8801/fast.mp4","size":230400}]
pos [{"t":76,"tag":"VIDEO","url":"http://127.0.0.1:8801/poster.jpg","size":230400}]
Enter fullscreen mode Exit fullscreen mode

Read that carefully:

Page LCP entries What was measured
nop H1, then VIDEO the video's first frame (fast.mp4)
pos VIDEO only the poster image (poster.jpg)

With a poster, the entry's element is still the <video>, but its url is the poster. That is the browser telling you which resource the clock is waiting on. The millisecond values are from localhost, so don't read anything into them; run your own check on a throttled profile.

5. ๐Ÿš€ Preload the poster

An image starts at low priority and may be bumped later, according to DebugBear. For a poster that is your LCP element, you want it early. You can't put fetchpriority on the poster attribute (web.dev notes this), so use a preload in the head:

<!-- in <head> -->
<link rel="preload" as="image" href="/poster.webp" fetchpriority="high">
Enter fullscreen mode Exit fullscreen mode
<video src="/fast.mp4" width="640" height="360" muted playsinline autoplay loop
       poster="/poster.webp"></video>
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก Tip: only preload the poster. DebugBear says Chrome does not support preloading video files, so <link rel="preload" as="video"> won't help.

In DebugBear's lab demo, LCP went from about 1.55 s (no poster) to 1.23 s (poster) to 1.2 s (poster preloaded at high priority). That's their page, not yours, but it shows the shape: the poster is the big win, the preload is the small one.

6. ๐Ÿ“‰ Does it matter on a throttled connection?

I repeated the test with a bigger clip (1280x720, 12 s, 2.03 MB) and Chrome's network emulation through the DevTools protocol: 150 ms latency, 1.6 Mbps down (the same slow-4G figures Lighthouse uses). Five runs per page, last LCP entry each time:

// throttle.js (excerpt)
const s = await context.newCDPSession(page);
await s.send('Network.enable');
await s.send('Network.emulateNetworkConditions', {
  offline: false, latency: 150,
  downloadThroughput: 1.6 * 1024 * 1024 / 8,
  uploadThroughput: 750 * 1024 / 8,
});
Enter fullscreen mode Exit fullscreen mode
b_nop  676 | 676 | 692 | 684 | 696   (VIDEO, big.mp4)
b_pos  488 | 452 | 456 | 476 | 440   (VIDEO, poster.webp)
b_pre  480 | 460 | 468 | 468 | 456   (VIDEO, poster.webp, + preload)
Enter fullscreen mode Exit fullscreen mode

Times are in ms. The poster moved LCP from roughly 680 ms to roughly 460 ms on this page. The preload made no measurable difference here, and that makes sense: the <video poster> sits in the first bytes of the HTML, so the browser finds it immediately. A preload earns its keep when the poster is set late, for example by a framework after hydration. Measure before you add it.

๐Ÿ’ก Tip: this is a synthetic mandelbrot clip on a tiny page, so absolute numbers are not transferable. Re-run it against your real template.

7. ๐Ÿงฏ Avoid the traps

Trap Why it hurts Fix
loading="lazy" on the hero video web.dev says LCP videos shouldn't use it; it also defers the poster until near the viewport remove it from above-the-fold video
preload="none" on an autoplaying hero you need the data immediately use preload="none" only on click-to-play video below the fold
Poster with a different aspect ratio DebugBear warns this causes re-renders export it at the video's ratio, add object-fit: cover as a fallback

For a decorative autoplaying clip the usual markup is autoplay muted loop playsinline. playsinline is required for autoplay on iOS (web.dev), and Chrome has allowed muted autoplay since version 66 (Chrome autoplay policy).

8. ๐Ÿงช Put it in CI

Turn the script into a gate: fail the build if the LCP entry's url is not your poster.

// assert-lcp.js (excerpt, untested in CI)
const last = (await pg.evaluate(() => window.__lcp)).at(-1);
if (!last.url.endsWith('poster.webp')) {
  console.error('LCP is not the poster:', last);
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Note: I ran the check above; I did not run this CI wrapper, so treat the excerpt as a starting point.

What's next

  • Check field data, not just lab: lab LCP on a fast machine flatters you.
  • Look at what happens after the poster paints: time to first frame is a separate problem.
  • If your clips are replacing GIFs, the encode matters too; see the next post on that.

Top comments (0)