completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Pick a preload settingSay what none, metadata and auto fetch before anyone presses Play, and what each browser does with no setting.
  • 02
    Get the picture on screen earlyUse a poster, and explain how Chrome counts a poster and a video's first frame for LCP.
  • 03
    Fetch 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.

When Asha saw the teaPop out in the code editor (opens in a new tab)JavaScript
// 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 top of the mint tea page on Asha's phone at 668 ms, 1,216 ms, 1,612 ms. Autoplay, no poster: words, an empty box, then words, an empty box, then the video's first frame. Autoplay with a poster: words, an empty box, then the poster, then the poster.668 ms1,216 ms1,612 msAutoplay, no posterThe top of the mint tea page at 665 ms: words, an empty boxwords, an empty boxThe top of the mint tea page at 665 ms: words, an empty boxwords, an empty boxThe top of the mint tea page at 1,599 ms: the video's first framethe video's first frameAutoplay with a posterThe top of the mint tea page at 666 ms: words, an empty boxwords, an empty boxThe top of the mint tea page at 1,214 ms: the posterthe posterThe top of the mint tea page at 1,214 ms: the posterthe poster
The top of the page as Chrome drew it on Asha's phone. Without a poster, the box stayed empty until the video's first frame. With one, the picture came about 400 ms sooner.

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.

Real-life analogyA photo on the menu, or a pot for everyone

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
The main idea

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 preload attribute.
  • 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.

Three preload settingsHTML
<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".
Real-life analogyOrdering at a café

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 auto as metadata on 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:

What each setting fetched before PlayPop out in the code editor (opens in a new tab)JavaScript
// 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 wait and auto: 459520 bytes, 100% of the file, 0 ms wait.
Bytes of brew.mp4 fetched before Play, out of 459,520: preload none, 0 bytes, 1,127 ms wait after Play; preload metadata, 328,500 bytes, no wait after Play; preload auto, 459,520 bytes, no wait after Play.Bytes of brew.mp4 before Play, out of 459,520The top of the page with preload="none", 8 seconds inpreload="none"Play: 1,127 ms wait0 bytes, 0%The top of the page with preload="metadata", 8 seconds inpreload="metadata"Play: no wait328,500 bytes, 71%The top of the page with preload="auto", 8 seconds inpreload="auto"Play: no wait459,520 bytes, 100%
What Chrome fetched before anyone pressed Play, on Slow 4G. The small pictures show the top of each page 8 seconds in: with preload="none", the box stayed empty.

Three findings:

  • none fetched 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.
  • metadata wasn'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".
  • auto fetched 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.

The preload settings, recorded in Chrome
SettingWhat it asks the browserWhat Chrome fetched before PlayThe 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 attributeThe 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".

The index at the end of the filePop out in the code editor (opens in a new tab)JavaScript
// 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.

Moving the index to the frontShell
ffmpeg -i brew-moov-end.mp4 -c copy -movflags +faststart brew.mp4

Line 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.

Playground: what each preload setting fetched
Asha's phone, 8 seconds in
The top of the mint tea page with No preload attribute, 8 seconds in
  1. Bytes of video before Play328,500
  2. Requests for the video before Playbytes=0-: 328,500 bytes, stopped early
  3. readyState 8 seconds in4, HAVE_ENOUGH_DATA
  4. What video.preload saidmetadata
  5. Wait after pressing Play1 ms
  6. Your browser, with no preload attributechecking…
Try it yourself
328,500 bytes before Play, 71% of the file

With no attribute, Chrome used metadata: the same bytes as preload="metadata".

Recorded in Chrome 157, on a phone's screen, with DevTools' Slow 4G and a CPU 4 times slower, over HTTP/2. Bytes and waits are medians of 3 loads; the picture and the requests come from the load with the middle bytes.

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.

Real-life analogyA film poster outside the cinema

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:

Reading LCP candidatesJavaScript
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).

A preload hint for the posterHTML
<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.

Playground: four ways to put the video at the top
What Asha saw
  1. The top of the mint tea page at 15 ms, Autoplay, no poster15 ms
  2. The top of the mint tea page at 665 ms, Autoplay, no poster665 ms
  3. The top of the mint tea page at 1,599 ms, Autoplay, no poster1,599 ms
  4. The top of the mint tea page at 1,793 ms, Autoplay, no poster1,793 ms
  5. The top of the mint tea page at 3,228 ms, Autoplay, no poster3,228 ms
  1. Words on the screen (FCP)668 ms
  2. LCP1,612 ms, brew.mp4
  3. Video started playing1,774 ms
  4. Bytes of the poster0
  5. Bytes of the video, 10 seconds in459,520
Try it yourself
LCP at 1,612 ms: the <video>, through brew.mp4

Each picture is a frame Chrome drew, in order: 15 ms, 665 ms, 1,599 ms, 1,793 ms, 3,228 ms.

Recorded in Chrome 157, on a phone's screen, with DevTools' Slow 4G and a CPU 4 times slower, over HTTP/2. The times are medians of 3 loads; the frames come from the load with the middle LCP time.

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:

A video that plays by itselfHTML
<video class="hero" autoplay muted loop playsinline  width="1280" height="720"  poster="brew-poster.jpg" src="brew.mp4"></video>
  • Line 1 holds the four. autoplay starts the video as soon as it can. muted turns the sound off. loop starts it again at the end. playsinline lets 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."

Real-life analogyA TV in the café window

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 backgroundPop out in the code editor (opens in a new tab)JavaScript
// 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 ms and The 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:

Dropping the sound trackShell
ffmpeg -i brew-with-sound.mp4 -c:v copy -an brew.mp4

Line 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.

A clip that waits until Asha is nearHTML
<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 width and height of 0" 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.

Lazy loading with a scriptHTML
<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 in data-src, and preload="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 clip far down the page, three waysPop out in the code editor (opens in a new tab)JavaScript
// 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.
A page with the brew guide clip after 4,800 px. No lazy loading: 75,989 bytes of the clip before scrolling, its first frame 23 ms after the scroll. loading="lazy": 0 bytes of the clip before scrolling, its first frame 825 ms after the scroll. IntersectionObserver: 0 bytes of the clip before scrolling, its first frame 849 ms after the scroll.screen4,800 pxof pagethe clipbefore scrollingfirst frame afterNo lazy loading75,989 bytes23 msloading="lazy"0 bytes825 msIntersectionObserver0 bytes849 ms
The clip sits after 4,800 px of page. Without lazy loading, Asha downloaded it before she ever scrolled. Lazy loaded, it cost nothing until she scrolled, then a wait of under a second.

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.

Playground: a clip far down the page, three ways
The clip
  1. Bytes before scrolling75,989
  2. Load event1,422 ms
  3. From the scroll to its first frame23 ms
  4. Bytes after it played75,989
  5. Your browser has loading on videochecking…
Try it yourself
75,989 bytes before Asha scrolled

The clip downloaded with the page, though it was far out of view. When she scrolled to it, it was ready.

Recorded in Chrome 157, on a phone's screen, with DevTools' Slow 4G and a CPU 4 times slower, over HTTP/2. The clip sits after 4,800 px of page. Medians of 3 loads.

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.

Real-life analogyA meal served course by course

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".

The master playlist, master.m3u8Text
#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.

Playing the stream with hls.jsJavaScript
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 version of each segmentPop out in the code editor (opens in a new tab)JavaScript
// 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 playing the 30-second stream while the network changed: Fast 4G from 0.0 s, 3G from 10.0 s, Fast 4G from 22.1 s. Segments played at 240p then 480p from 1.9 s, 720p from 6.1 s, 240p from 16.1 s. It waited for data 0.9 s and 1.2 s.NetworkFast 4G3GFast 4GPlaying240p, 480p720p240pWaiting0.9 s1.2 s0 s10 s20 s30 sA frame of the stream playing at 720p720pA frame of the stream playing at 240p240p
Each block in the middle row is one 2-second segment as it played, shaded at 720p. After the link dropped to 3G, every segment came at 240p, with two short waits.

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 segment on a 3G linkPop out in the code editor (opens in a new tab)JavaScript
// 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 video and 240p: 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.

Playground: the stream, segment by segment
A frame at 240p
A frame of the stream playing at 240p
  1. Version240p, 426 by 240
  2. Bytes44,809
  3. Downloaded1,640 ms to 1,855 ms
  4. Network when it startedFast 4G
  5. hls.js's estimate after it500,000 bits a second
  6. Started playing1,921 ms
Try it yourself
Segment 0: 240p, fetched on Fast 4G

It covers 0 to 2 seconds of the video. hls.js chose its version from its estimate of the link.

Recorded in Chrome 157, on a phone's screen, with DevTools' Slow 4G and a CPU 4 times slower, over HTTP/2. One recorded run of hls.js 1.7.4, keeping 6 seconds ahead, while the network changed from Fast 4G to 3G and back.

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?

Which settings fit each video?
  • 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
Try it yourself
0 of 6 correct

Sort each video into the settings it should use.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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. Only preload="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 loading on 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.

Exercise 1 · Warm-upThe same bytes from a longer video

Suppose Chrome fetched the same 328,500 bytes from a longer video. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const whole = 2000000;
const fetched = 328500;

console.log(`${Math.round((fetched / whole) * 100)}%`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWhich one counts?

    This code picks which of the video's two moments LCP counts, the way Chrome does. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWhat did the video have?

      In the recorded page with preload="none", what was the video's readyState 8 seconds in?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThe first 720p segment

        Segment 2 was the first one hls.js fetched at 720p. How many bytes was it?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWhere loading lives

          In Chrome, which interface defines the loading property: HTMLVideoElement or HTMLMediaElement?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThe standard's name for a version

            The HLS standard has its own name for each version a master playlist lists. What is it?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what the browser fetched before anyone watched, and what was on the screen.

              Video performance quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the second line of this share code print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              2. 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.

              3. 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.

              4. Question 4 of 7What does preload="none" cost a visitor who does press Play?

                Choose an answer to see the explanation.

              5. 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.

              6. Question 6 of 7In adaptive streaming, who picks the version of the next segment?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why can a muted video play by itself without a click?

                Choose an answer to see the explanation.

              Key takeaways

              • preload is a hint. none fetches nothing; metadata fetched 71% of a short file in Chrome; auto fetches 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:

              Coming next: Embeds and facades. Video players, maps, and social posts load megabytes. Show a light facade until the user asks for more.

              CompleteFrontend Clear concepts. Working examples.