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 aposter, 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.*
-rw-r--r-- 1 root root 20546 poster.jpg
-rw-r--r-- 1 root root 13002 poster.webp
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>
<!-- 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>
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>
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
undefinedfor both pages, because my server sentContent-Type: video/mp4for 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();
})();
$ 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}]
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">
<video src="/fast.mp4" width="640" height="360" muted playsinline autoplay loop
poster="/poster.webp"></video>
๐ก 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,
});
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)
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);
}
โ ๏ธ 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)