Video performance
Make video cheap and fast: what preload fetches, how posters and first frames count for LCP, background video, lazy loading, and adaptive streaming.
- 01Pick a preload settingSay what none, metadata and auto fetch before anyone presses Play, and what each browser does with no setting.
- 02Get the picture on screen earlyUse a poster, and explain how Chrome counts a poster and a video's first frame for LCP.
- 03Fetch video only when it's neededLazy load a video with loading="lazy" or an IntersectionObserver, and know when adaptive streaming beats one file.
A brewing video at the top of the page
Ravi wants the mint tea page to feel alive. He makes a six-second video of steam rising from a cup of mint tea. He puts it at the top of the page, above the name of the tea. It plays by itself, with no sound, over and over.
Asha opens the page on her phone, on a slow mobile link. When does she see the tea? And what does the video cost her?
The course loaded the page three times in Chrome 157, on a phone's screen. It used DevTools' Slow 4G network, and made the CPU 4 times slower. Then it did the same with one change: a poster. A poster is a picture the browser shows in the video's place, until the video has a frame to show.
// Asha's phone, in ms after she opened the page, as recorded (the median of 3 loads)const noPoster = { words: 668, tea: 1612 };const withPoster = { words: 668, tea: 1216 };const videoBytes = 459520; console.log(`No poster: words at ${noPoster.words} ms, the tea at ${noPoster.tea} ms`);console.log(`With a poster: words at ${withPoster.words} ms, the tea at ${withPoster.tea} ms`);console.log(`The video, both times: ${videoBytes} bytes`);- Line 1 is a comment. Each time is in milliseconds (ms) after the visit started. It's the median of three loads: the middle one once they're sorted.
- Line 2 is Ravi's page: the words appeared at 668 ms, and the tea at 1,612 ms. Line 3 is the page with a poster: the same words, and the tea at 1,216 ms.
- Line 4 is the video's size, 459,520 bytes. Asha downloaded all of it both times.
- Line 6 prints
No poster: words at 668 ms, the tea at 1612 ms. - Line 7 prints
With a poster: words at 668 ms, the tea at 1216 ms. - Line 8 prints
The video, both times: 459520 bytes.
Without a poster, Asha read the words under an empty white box for almost a second. With one, the picture came about 400 ms sooner. Here is what her screen showed:
The poster helped her eyes, but not her data plan. The video still downloaded, all of it, whether she watched or not. A video is often the heaviest file on a page. This one weighed about 54 times as much as its poster.
Ravi could brew a full pot of mint tea for everyone who walks in, in case they want some. Most leave without a sip. Or he can put a photo of the tea on the menu, and brew a cup when someone orders. The photo costs almost nothing, and everyone can see what's on offer.
- In real life: A photo of the tea on the menu
- In video: The poster: 8,583 bytes
- In real life: A full pot, brewed for everyone who walks in
- In video: A video that downloads by itself: 459,520 bytes
- In real life: Brewing a cup when someone orders
- In video: Fetching the video only after Play
Show a small poster first, and fetch the video only when someone is going to watch it. A video that plays by itself has to download at once, so keep it short. A long video comes in small pieces, each at a size the visitor's link can carry.
The rest of the lesson takes five questions, one section each:
- How much does the browser fetch before Play? The
preloadattribute. - When does the video count as painted? Posters and LCP.
- What does a video that plays by itself cost? Autoplaying background video.
- How do you hold back a video far down the page? Lazy loading.
- What about a long video on a changing link? Adaptive streaming.
How much the preload attribute fetches
Not every video plays by itself. Many wait for a press on Play. Before that press, how much should the browser download? You tell it with the preload attribute.
<video preload="none" src="brew.mp4"></video>
<video preload="metadata" src="brew.mp4"></video>
<video preload="auto" src="brew.mp4"></video>- Line 1,
none: the author doesn't expect the visitor to need the video, so fetch nothing. - Line 2,
metadata: fetch the video's metadata, the facts about it. The HTML standard lists "dimensions, track list, duration, etc.", "and maybe even the first few frames". - Line 3,
auto: the standard allows anything "up to and including optimistically downloading the entire resource".
At a café, the waiter can wait for your order. He can bring the menu and a glass of water, so you know what's on. Or the kitchen can start cooking the special before you sit down, in case you want it. On a busy night, the manager says "menus only".
- In real life: The waiter waits for your order
- In HTML:
preload="none" - In real life: He brings the menu and a glass of water
- In HTML:
preload="metadata": the length, the size, the first frame - In real life: The kitchen starts the special before you order
- In HTML:
preload="auto" - In real life: "Menus only" on a busy night
- In HTML: Chrome treating
autoasmetadataon mobile data
The setting is a hint. The standard says it "may be ignored altogether, for example based on explicit user preferences or based on the available connectivity." So the course measured what Chrome really did. It loaded a page with the brewing video, no poster, and a Play button. It waited 8 seconds, as if Asha were reading. Then it pressed Play:
// Bytes of brew.mp4 before anyone pressed Play, and the wait after Play,// on Slow 4G, as recorded (the median of 3 loads)const whole = 459520;const settings = { none: [0, 1127], metadata: [328500, 0], auto: [459520, 0] }; for (const [setting, [bytes, wait]] of Object.entries(settings)) { console.log(`${setting}: ${bytes} bytes, ${Math.round((bytes / whole) * 100)}% of the file, ${wait} ms wait`);}- Lines 1 and 2 are comments. Line 3 is the whole file, 459,520 bytes.
- Line 4 holds each setting's bytes before Play, and the wait after it, in ms.
- Lines 6 to 8 print a line per setting.
- The first is
none: 0 bytes, 0% of the file, 1127 ms wait. - Then come
metadata: 328500 bytes, 71% of the file, 0 ms waitandauto: 459520 bytes, 100% of the file, 0 ms wait.
preload="none", the box stayed empty.Three findings:
nonefetched nothing, and the box stayed empty. The price came later: after the press, Asha waited 1,127 ms while the first part of the video crossed the network.metadatawasn't small. Chrome had the first frame at about 1.6 seconds, kept downloading, and only stopped at about 3.1 seconds. In all, it fetched 71% of the file. web.dev warns about this: it "can result in more data being downloaded than desired".autofetched the whole file. Play started at once, but every visitor paid for it.
Read each row across: what a setting asks for, and what Chrome did with it on Asha's phone.
| Setting | What it asks the browser | What Chrome fetched before Play | The wait after Play |
|---|---|---|---|
preload="none" | Fetch nothing until someone presses Play. | 0 bytes. | 1,127 ms, while the video started downloading. |
preload="metadata" | Fetch the length and size, and maybe the first frames. | 328,500 bytes, 71% of the file. | None. It played at once. |
preload="auto" | Fetch as much as helps, up to the whole file. | 459,520 bytes, the whole file. | None. It played at once. |
No preload attribute | The browser decides. The standard suggests metadata. | 328,500 bytes, the same as metadata. | None. It played at once. |
With no preload attribute
Most pages leave preload out. Then each browser picks, and its source code shows the choice. The standard only suggests metadata "as a compromise between reducing server load and providing an optimal user experience."
- Chrome: metadata. On a mobile connection, it uses metadata even when the page asks for
auto. - Firefox: metadata, and none on a mobile connection.
- Safari's engine, WebKit: auto. On iPhone and iPad, its default settings allow at most the metadata.
The course checked Chrome's mobile rule. It had DevTools tell the page it was on a 4G mobile connection, and loaded the page with preload="auto". Chrome fetched 328,500 bytes, as for metadata. Reading video.preload gave "metadata" too. The playground below shows each setting, and your own browser's choice.
The index at the start of the file
To show anything, the browser first needs the file's index. That's the part of an MP4 file that says where each frame is. MP4 calls it the moov box. FFmpeg is a free tool that converts and edits video files. Its docs say the index "is usually written at the end of the file, but it can be moved to the start for better playback". web.dev adds that the metadata "may not be stored at the beginning of the file". The course saved the same video with its index at the end. Then it loaded it with preload="metadata".
// preload="metadata", with the file's index at its start or at its end, as recordedconst indexFirst = { requests: 1, metadataAt: 1600, bytes: 328500 };const indexLast = { requests: 3, metadataAt: 2926, bytes: 362036 }; console.log(`Index at the end: ${indexLast.requests - indexFirst.requests} more requests, ${indexLast.metadataAt - indexFirst.metadataAt} ms later`);- Line 2 is the usual file: one request, metadata at 1,600 ms, 328,500 bytes before Play.
- Line 3 is the file with its index at the end: 362,036 bytes before Play.
- Line 5 prints
Index at the end: 2 more requests, 1326 ms later.
Chrome started at the beginning, found no index, asked for the end of the file, and then came back. One command moves the index to the front, without encoding the video again.
ffmpeg -i brew-moov-end.mp4 -c copy -movflags +faststart brew.mp4Line 1 reads the file with its index at the end. -c copy copies the video as it is, and -movflags +faststart writes the index at the start.

- Bytes of video before Play328,500
- Requests for the video before Playbytes=0-: 328,500 bytes, stopped early
- readyState 8 seconds in4, HAVE_ENOUGH_DATA
- What video.preload saidmetadata
- Wait after pressing Play1 ms
- Your browser, with no preload attributechecking…
With no attribute, Chrome used metadata: the same bytes as preload="metadata".
Why it matters: for a video most visitors won't play, use preload="none" and a poster. Accept the short wait after Play. Use metadata only when the video's length must show before Play. Know that it can cost most of a short file.
Posters and the largest paint
Before a video has a frame, what does the box show? The HTML standard says it shows the poster, if there is one, "or else transparent black". Transparent means you see the page behind it: Asha saw a white gap.
A cinema hangs a poster outside for every film. You see what's showing from across the street, long before the film starts. The poster is one picture, so it goes up at once. The film runs only when you go in.
- In real life: The poster outside
- In video:
poster="brew-poster.jpg" - In real life: The film
- In video: The video's frames, in
brew.mp4 - In real life: Seeing the poster from across the street
- In video: The poster painted early, before any video data
- In real life: The film starting inside
- In video: The video's first frame
Ravi's poster is the video's first frame, saved as a JPEG 720 pixels wide: 8,583 bytes. That's under 2% of the video.
How LCP counts a video
Largest Contentful Paint, LCP, is the moment the biggest piece of content was painted (see How LCP works). For a video, two moments can count. The Paint Timing standard counts "a video element with a poster frame". Chrome also counts a video's first frame. Its changelog for Chrome 116 says "a video element can become an LCP candidate, exactly like an image". The time is when "the first video frame is presented on screen."
A video with a poster has both. web.dev says LCP uses the poster's load time or the first frame's time, "whichever is earlier". Chrome's source code keeps one LCP record for each video element, "to avoid counting both the poster and first frame". You can watch the candidates yourself:
new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(entry.element?.tagName, entry.url, Math.round(entry.startTime)); }}).observe({ type: "largest-contentful-paint", buffered: true });- Line 1 makes a performance observer. Its function runs with each batch of new entries.
- Line 2 goes through the entries. Each one is an LCP candidate: a new biggest element.
- Line 3 prints the element's tag, the address of its picture (
url), and the time in ms. - Line 5 asks for LCP entries, including the ones from before the observer started (
buffered).
On Ravi's page, the last candidate was VIDEO, the address of brew.mp4, and 1612: the video's first frame. With the poster, it was VIDEO, the address of brew-poster.jpg, and 1216. The video itself started playing at about 1.8 seconds, but LCP had already counted the poster.
Making the poster come sooner
The poster is an image, but you can't give the poster attribute a fetchpriority. web.dev suggests a preload hint instead, when the poster is the LCP element (see Resource hints).
<link rel="preload" as="image" href="brew-poster.jpg" fetchpriority="high"><video autoplay muted loop playsinline poster="brew-poster.jpg" src="brew.mp4"></video>- Line 1 tells the browser, from the page's head, to fetch the poster early, as an image, at high priority.
- Line 2 is the video, pointing at the same file, so the browser uses the copy it already has.
web.dev adds a warning. If the video isn't the largest element, this can delay the real LCP element. That's because both downloads share the link. Try the four page versions here, and compare their LCP.
15 ms
665 ms
1,599 ms
1,793 ms
3,228 ms
- Words on the screen (FCP)668 ms
- LCP1,612 ms, brew.mp4
- Video started playing1,774 ms
- Bytes of the poster0
- Bytes of the video, 10 seconds in459,520
Each picture is a frame Chrome drew, in order: 15 ms, 665 ms, 1,599 ms, 1,793 ms, 3,228 ms.
Why it matters: give every video a poster. It costs a few kilobytes, puts a picture on the screen early, and is often what LCP counts.
Autoplaying background video
Ravi's video plays by itself. It needs four attributes:
<video class="hero" autoplay muted loop playsinline width="1280" height="720" poster="brew-poster.jpg" src="brew.mp4"></video>- Line 1 holds the four.
autoplaystarts the video as soon as it can.mutedturns the sound off.loopstarts it again at the end.playsinlinelets an iPhone play it in the page, not full screen. - Line 2 gives the box its shape before anything loads, so nothing jumps.
- Line 3 is the poster and the video file.
Why muted? A silent video may start by itself. One with sound usually may not, until the visitor has used the site. Chrome's policy says "Muted autoplay is always allowed." WebKit, Safari's engine, allows <video muted> too. It also allows videos whose "source media contains no audio tracks."
A café puts a TV in its window. It plays a short clip of tea being poured. The sound is off, and the clip runs over and over, from opening to closing. It plays whether anyone looks or not, and the café pays for the power all day.
- In real life: Switched on when the café opens
- In HTML:
autoplay - In real life: The sound turned off
- In HTML:
muted - In real life: The same clip, over and over
- In HTML:
loop - In real life: Playing in the window, not on a big screen at the back
- In HTML:
playsinline - In real life: The power bill, paid whether anyone looks
- In HTML: All 459,520 bytes, whether Asha watches or not
Autoplay fetches everything
Autoplay beats preload. The standard says autoplay "can override the preload attribute", since a video that plays has to download first. Chrome's source does the same: a video allowed to autoplay gets auto. On Asha's phone, Chrome asked for brew.mp4 at about 0.65 seconds. It had every byte by about 3.8 seconds, with or without the poster.
A video behind the whole page
A background video fills the screen behind the words. The course tried that too. Its CSS stretched the same video over the whole phone screen.
// The full-screen background page, as recorded (the median of 3 loads)const lcp = { element: "p", time: 668 };const videoBytes = 459520; console.log(`LCP: the <${lcp.element}> text, at ${lcp.time} ms`);console.log(`The video still cost ${videoBytes} bytes`);- Line 2 is the LCP entry: a paragraph of text, at 668 ms. Line 3 is the video's bytes.
- Lines 5 and 6 print
LCP: the <p> text, at 668 msandThe video still cost 459520 bytes.
The video played from about 1.8 seconds and covered the whole screen. It never became the LCP element. web.dev says Chromium skips "Elements that cover the full viewport, that are likely considered as background rather than content". The viewport is the part of the page you can see. So LCP looked great, while Asha still paid for every byte. Pick Full-screen background in the playground above to see its frames.
A sound track nobody hears
A muted video can still carry a sound track. web.dev says to remove it, "even if the audio stream already present in the source video file is silent." The course tested it. It added six seconds of silence to the brewing video: 464,265 bytes instead of 459,520. FFmpeg's -an removes it:
ffmpeg -i brew-with-sound.mp4 -c:v copy -an brew.mp4Line 1 reads the file with sound. -c:v copy keeps the video as it is, and -an means "no audio". Here it saved 4,745 bytes.
Why it matters: an autoplaying video costs its whole file on every visit. It costs it even when nobody looks, and even when LCP doesn't count it. Keep it short, small and silent, with a poster.
Lazy loading video, with and without loading=lazy
Further down the mint tea page, in the brew guide, Ravi adds a four-second clip of the cup, close up. It loops by itself, like an animated GIF. It sits after 4,800 pixels of page, so Asha only sees it if she scrolls a long way. But web.dev warns about videos like this. They "begin downloading immediately, even if they are located outside of the initial viewport."
There's a fix. Lazy loading means holding a file back until the visitor comes near it. In Loading images well, the browser did it for images. It was like a waiter who sets off just before you sit down. Videos can have the same attribute.
<video id="guide" autoplay muted loop playsinline loading="lazy" width="640" height="360" src="brew-guide.mp4"></video>- Line 1 is the clip, playing by itself and muted, with
loading="lazy". - Line 2 gives it a size. MDN says this matters for lazy videos. Without it, they have "a
widthandheightof0" and may never load.
The HTML standard has loading on <video> and <audio> too. Its purpose is "to indicate the policy for loading media resources that are outside the viewport." Other things wait too. A lazy video that plays by itself waits to start playing. Its poster waits as well.
A browser that doesn't know the attribute ignores it, and loads the clip at once. For those browsers, a few lines of script do the same job. They use an IntersectionObserver: a browser object that reports when an element comes near the screen.
<video id="guide" muted loop playsinline preload="none" width="640" height="360" data-src="brew-guide.mp4"></video><script>const guide = document.getElementById("guide");new IntersectionObserver((entries, observer) => { if (!entries[0].isIntersecting) return; guide.src = guide.dataset.src; guide.play(); observer.disconnect();}, { rootMargin: "200px" }).observe(guide);</script>- Lines 1 and 2 are the clip with no
src. Its file waits indata-src, andpreload="none"keeps it idle. - Line 4 finds the clip. Line 5 makes an observer, and line 10 points it at the clip.
- Line 6 does nothing until the clip comes near.
rootMargin: "200px", on line 10, means "200 pixels before it reaches the screen". - Line 7 gives the clip its file. Line 8 plays it. Line 9 stops watching, since it only has to happen once.
The course loaded the page all three ways, waited 6 seconds, then scrolled to the clip.
// The brew guide clip, after 4,800 px of page, as recorded (the median of 3 loads)const ways = { "No lazy loading": { before: 75989, load: 1422, wait: 23 }, 'loading="lazy"': { before: 0, load: 653, wait: 825 }, IntersectionObserver: { before: 0, load: 649, wait: 849 },}; for (const [way, { before, load, wait }] of Object.entries(ways)) { console.log(`${way}: ${before} bytes before scrolling, load event at ${load} ms, ${wait} ms to the first frame`);}- Line 1 is a comment. Lines 3 to 5 hold each way's numbers: bytes before scrolling, the load event, and the wait.
- Line 9 prints a line per way.
- The first is
No lazy loading: 75989 bytes before scrolling, load event at 1422 ms, 23 ms to the first frame. - The last is
IntersectionObserver: 0 bytes before scrolling, load event at 649 ms, 849 ms to the first frame.
Without lazy loading, Asha downloaded the whole clip before she ever scrolled. It also held back the page's load event, to 1,422 ms instead of about 650. Lazy loaded, it cost nothing until she scrolled. Then she waited 825 ms for its first frame, or 849 with the script. Both ways are close: the script just does by hand what the attribute does.
- Bytes before scrolling75,989
- Load event1,422 ms
- From the scroll to its first frame23 ms
- Bytes after it played75,989
- Your browser has loading on videochecking…
The clip downloaded with the page, though it was far out of view. When she scrolled to it, it was ready.
Why it matters: a video that plays by itself far down the page should be lazy loaded. Use loading="lazy", and an IntersectionObserver where it isn't supported. Never lazy load the video at the top. web.dev says a video that is the LCP element shouldn't be lazy loaded, just like an LCP image.
Adaptive streaming basics
Ravi's next idea is a long brewing class. Asha might watch it on a train. Her link will be fast at one moment and slow the next. One file at one size can't suit both.
Adaptive streaming cuts the video into short segments, a few seconds each, and keeps every segment at several sizes. Before each segment, the player picks the size the link can carry right now. Two formats do this: HLS, HTTP Live Streaming, and DASH, short for MPEG-DASH.
At a long dinner, the kitchen sends one course at a time. Before each course, the waiter checks how busy the kitchen is. When it's quiet, you get the full plate. When it's swamped, a smaller one, so the next course still comes before you finish this one.
- In real life: One course
- In streaming: One segment: 2 seconds of video
- In real life: A full plate or a small one
- In streaming: The 720p or the 240p version
- In real life: The waiter checking the kitchen
- In streaming: The player estimating the link's speed
- In real life: Finishing a course before the next one comes
- In streaming: A stall: the video waits for data
The course made 30 seconds of the brewing video into HLS: its six seconds, five times over. It cut them into 2-second segments, at three sizes. A master playlist lists the versions. The HLS standard says each one "describes a different version of the same content".
#EXTM3U#EXT-X-VERSION:7#EXT-X-INDEPENDENT-SEGMENTS#EXT-X-STREAM-INF:BANDWIDTH=304968,AVERAGE-BANDWIDTH=247917,RESOLUTION=426x240,CODECS="avc1.640015"240p/index.m3u8#EXT-X-STREAM-INF:BANDWIDTH=898632,AVERAGE-BANDWIDTH=768117,RESOLUTION=854x480,CODECS="avc1.64001e"480p/index.m3u8#EXT-X-STREAM-INF:BANDWIDTH=2299080,AVERAGE-BANDWIDTH=1975911,RESOLUTION=1280x720,CODECS="avc1.64001f"720p/index.m3u8- Line 1 marks the file as a playlist. Lines 2 and 3 say which version of HLS it uses.
- Line 4 describes the smallest version. It gives its peak bits per second (
BANDWIDTH), its average, and its size in pixels. Last comes its codec: the way the video is compressed. Line 5 is the address of its own playlist, which lists its 15 segments. - Lines 6 and 7 are the 480p version. Lines 8 and 9 are 720p, at about 2 million bits per second on average.
Safari plays HLS by itself. Where a browser doesn't, a player library such as hls.js does it. Its README says it "relies on HTML5 video and MediaSource Extensions for playback". Media Source Extensions let JavaScript feed a <video> element piece by piece. dash.js does the same for DASH.
const video = document.getElementById("brew");const hls = new Hls({ maxBufferLength: 6, maxMaxBufferLength: 6 });hls.loadSource("master.m3u8");hls.attachMedia(video);video.play();- Line 1 finds the video element.
- Line 2 makes a player. The course told it to fetch at most 6 seconds ahead. Its default is 30. That way, a 30-second clip shows what a long film does.
- Line 3 gives it the master playlist. Line 4 joins it to the video. Line 5 starts playing.
The course played the stream on a phone while DevTools changed the network. The order was: Fast 4G, then 3G from about 10 seconds, then Fast 4G again from about 22. hls.js wrote down each segment it fetched.
// The 15 segments hls.js fetched, in order, as recorded
const levels = [0, 1, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0];
const names = ["240p", "480p", "720p"];
console.log(levels.map((level) => names[level]).join(" "));- Line 2 holds the version of each of the 15 segments, in order: 0 is the smallest. Line 3 names them.
- Line 5 prints
240p 480p 720p 720p 720p 720p 720p 240p 240p 240p 240p 240p 240p 240p 240p.
hls.js started small, then climbed to 720p while the link was fast. When the link dropped to 3G, it switched every segment after that to 240p. The video still stopped twice to wait, for 0.9 and 1.2 seconds. Without the small version, things would have been much worse.
// One 2-second segment of each version, on average, as recorded,
// over DevTools' 3G speed of 400 kbps
const segmentBytes = { "720p": 493978, "240p": 61979 };
const kbps = 400;
for (const [version, bytes] of Object.entries(segmentBytes)) {
console.log(`${version}: ${((bytes * 8) / (kbps * 1000)).toFixed(1)} s to download 2 s of video`);
}- Line 3 holds the average size of one 2-second segment at 720p and at 240p. Line 4 is DevTools' 3G speed: 400 kilobits a second.
- Line 7 turns bytes into seconds. It prints
720p: 9.9 s to download 2 s of videoand240p: 1.2 s to download 2 s of video.
At 720p, each 2 seconds of video would have taken almost 10 seconds to arrive. And when the link came back, hls.js stayed on 240p to the end. Its estimate of the link climbs slowly: after the last segment, it was 958,331 bits per second. Its docs say it only switches up when 0.7 times its estimate is more than the next version needs. 0.7 times 958,331 is 670,832, and 480p needs 768,117 on average.

- Version240p, 426 by 240
- Bytes44,809
- Downloaded1,640 ms to 1,855 ms
- Network when it startedFast 4G
- hls.js's estimate after it500,000 bits a second
- Started playing1,921 ms
It covers 0 to 2 seconds of the video. hls.js chose its version from its estimate of the link.
Why it matters: for long videos, or for visitors on changing links, send segments at several sizes. The player then drops to a size the link can carry, instead of stopping every few seconds.
Pick the settings for each video
For each video below, ask two questions. Does it play by itself, or wait for Play? And is it short, or long?
- A two-minute video at the top of a product page that most visitors never play
- An interview with the tea grower, in the middle of an article
- Six seconds of steam rising from a cup, looping behind the shop's name
- A looping clip of leaves being sorted, in the brew guide far down the page
- A 40-minute tea tasting class
- A live stream from the tea farm
Sort each video into the settings it should use.
Common mix-ups
- "preload="metadata" only fetches a few bytes." No. Chrome fetched 328,500 of the video's 459,520 bytes before anyone pressed Play.
- "A video without a poster can't be the LCP element." No. Since Chrome 116, the first frame counts. On Ravi's page, it was the LCP, at 1,612 ms.
- "A poster means the video isn't downloaded." No. With
autoplay, the whole video still downloaded. Onlypreload="none", without autoplay, waits for Play. - "A full-screen background video is the LCP element." No. Chrome left it out, and counted the text. The video still cost 459,520 bytes.
- "A video far down the page waits until it's seen." No. Without lazy loading, the clip downloaded before Asha scrolled.
- "Every browser lazy loads video." No. Browsers that don't support
loadingon video ignore it. Use an IntersectionObserver for them. - "Adaptive streaming never stalls." No. It stalled twice on 3G. The small segments kept the waits short: at 720p, each 2 seconds of video would have taken almost 10 seconds to arrive.
Practice exercises
Answer each one first, then open the hints if you need them.
Suppose Chrome fetched the same 328,500 bytes from a longer video. What does the last line print?
const whole = 2000000;
const fetched = 328500;
console.log(`${Math.round((fetched / whole) * 100)}%`);16%. 328,500 bytes is 71% of a 459,520-byte video, but only 16% of a 2,000,000-byte one. How much metadata costs depends on the file.
This code picks which of the video's two moments LCP counts, the way Chrome does. What does the last line print?
const poster = { url: "brew-poster.jpg", time: 1216 };
const firstFrame = { url: "brew.mp4", time: 1810 };
const counted = poster.time <= firstFrame.time ? poster : firstFrame;
console.log(counted.url);brew-poster.jpg. The poster was painted first, so it's the one LCP counts. The video's frames came later, in the same box.
In the recorded page with preload="none", what was the video's readyState 8 seconds in?
0, HAVE_NOTHING. With preload="none", the browser fetched nothing, so the video knew nothing about itself yet.
Segment 2 was the first one hls.js fetched at 720p. How many bytes was it?
555,755 bytes. That's 2 seconds of 720p video. The 240p segments were about 62,000 bytes each.
In Chrome, which interface defines the loading property: HTMLVideoElement or HTMLMediaElement?
HTMLMediaElement. The property is defined on the interface shared by <video> and <audio>, as the HTML standard writes it, so both get it.
The HLS standard has its own name for each version a master playlist lists. What is it?
A Variant Stream. "A Master Playlist provides a set of Variant Streams, each of which describes a different version of the same content." The standard keeps the word Renditions for other choices, like a language.
Check your understanding
For each question, ask what the browser fetched before anyone watched, and what was on the screen.
Question 1 of 7What does the second line of this share code print?
Read the code, then predictconst whole = 459520; const fetched = { none: 0, auto: 459520 }; console.log(`${Math.round((fetched.none / whole) * 100)}%`); console.log(`${Math.round((fetched.auto / whole) * 100)}%`);Choose an answer to see the explanation.
Question 2 of 7In the recording, which file did Chrome name in the LCP entry of the page with a poster?
Choose an answer to see the explanation.
Question 3 of 7In the recording, why did the full-screen background video never become the LCP element?
Choose an answer to see the explanation.
Question 4 of 7What does preload="none" cost a visitor who does press Play?
Choose an answer to see the explanation.
Question 5 of 7A muted, looping clip far down a page plays by itself, with no loading attribute. When does it download?
Choose an answer to see the explanation.
Question 6 of 7In adaptive streaming, who picks the version of the next segment?
Choose an answer to see the explanation.
Question 7 of 7Why can a muted video play by itself without a click?
Choose an answer to see the explanation.
Key takeaways
preloadis a hint.nonefetches nothing;metadatafetched 71% of a short file in Chrome;autofetches it all.- Give every video a poster. Chrome counts the poster or the first frame for LCP, whichever comes first.
- Keep the MP4's index at the start of the file, with FFmpeg's +faststart.
- Autoplay downloads the whole video. It must be muted. A full-screen one doesn't count for LCP, but still costs its bytes.
- Lazy load videos further down with
loading="lazy", or an IntersectionObserver where it isn't supported. - For long videos, adaptive streaming sends small segments at sizes the link can carry.
Remember the one-liner.
Show a poster, fetch the video only when someone will watch, and stream long videos in pieces the link can carry.
Sources
The facts on this page come from these standards, docs and source code:
- HTML Standard: media elements and the video element, WHATWG.
- <video>: the Video Embed element, MDN.
- Video performance, web.dev.
- Lazy loading video, web.dev.
- Fast playback with audio and video preload, web.dev.
- HTMLMediaElement::PreloadType, Chromium 157, the Chromium project.
- HTMLMediaElement::GetPreloadDefault, Firefox, Mozilla.
- HTMLMediaElement's preload, WebKit, the WebKit project.
- MediaElementSession::effectivePreloadForElement, WebKit, the WebKit project.
- MediaDataLoadsAutomatically, WebKit's preferences, the WebKit project.
- FFmpeg Formats Documentation: the MP4 muxer, the FFmpeg project.
- Paint Timing, the W3C.
- Largest Contentful Paint (LCP), web.dev.
- LCP change in Chrome 116: videos and animated images, the Chromium project.
- ImagePaintTimingDetector::RecordImage, Chromium 157, the Chromium project.
- Autoplay policy in Chrome, Chrome for Developers.
- New <video> Policies for iOS, WebKit.
- HTMLMediaElement's loading attribute, Chromium 157, the Chromium project.
- RFC 8216: HTTP Live Streaming, the IETF.
- hls.js 1.7.4, the README, the hls.js project.
- hls.js 1.7.4, the API docs, the hls.js project.
- Media Source Extensions, the W3C.
- dash.js, the DASH Industry Forum.
- Chrome DevTools' network presets (NetworkManager.ts), the Chrome DevTools project.
Coming next: Embeds and facades. Video players, maps, and social posts load megabytes. Show a light facade until the user asks for more.