Benjamin Elder
poststwittergithub

Lazy Loading YouTube Videos

web:movie-camera:

December 12th, 2020


First, let me acknowledge up-front that this is neither a novel problem nor a novel solution. This is simply what I cobbled together to fit my own needs, I thought I’d share about how this went / works.

Why Lazy Load?

YouTube is a pretty ubiquitous for video hosting and very easy to embed. For most videos you can just open the video on youtube.com, click “share”, click “embed”, and finally copy + paste the generated <iframe> into your page source. Done!

Unfortunately standard embedded YouTube videos do have drawbacks …

The drawback that kicked off this post is that they are still pretty bandwidth intensive even when not played, at nearly a megabyte (~820 kB) per video just to embed it in the page!

If you have a page with a single video this is not so bad, especially on a desktop websitie, but on a 3G connection this is rather a lot. Two videos is enough to completely consume a reasonable bandwidth budget for a webpage. Quoting from the chrome lighthouse docs:

Aim to keep your total byte size below 1,600 KiB. This target is based on the amount of data that can be theoretically downloaded on a 3G connection while still achieving a Time to Interactive of 10 seconds or less. 1

A fairly obvious approach to avoiding this is to replace the video embeds with placeholders and then only load the video when the user clicks “play”. And indeed searching for “lazy load youtube video” surfaced many variations on this technique, but none of the ones I looked at were quite what I was looking for to use on static sites like this one (built with hugo).

In particular I have the following requirements:

The Placeholder

One of the first useful tricks for building this is knowing that we can leverage predictable YouTube video thumbnail URLs like: https://i3.ytimg.com/vi/$video_id/maxresdefault.jpg

For example the video https://youtube.com/watch?v=rPppjjvjQlk would have a thumbnail at https://i3.ytimg.com/vi/rPppjjvjQlk/maxresdefault.jpg. Putting this in an <img> tag gives us:

In theory all videos >= 720p resolution should have a maxresdefault.jpg thumbnail.

In reality, some videos seem to be missing it anyhow, such as https://youtube.com/watch?v=BPVO2mcfjJk, which gives us:

… That doesn’t look so good! (Also it’s returning a 404)

To fix this, we can switch this video to the next-best qualty hqdefault.jpg thumnail instead, which gives us:

That’s a bit better, but still leaves us with a problem: The native youtube embed will not show

To fix this we just need a little layout tweak + some css:

HTML:

1<!--wrapper div-->
2<div class="video-wrapper lazyt">
3  <!-- the placeholder image -->
4  <img class="placeholder" src="https://i3.ytimg.com/vi/BPVO2mcfjJk/hqdefault.jpg">
5</div>

CSS:

 1/* style wrapper div for video embeds */
 2.video-wrapper {
 3  /*
 4  allow child element at width / height: 100%
 5  to responsively scale with 16:9 ratio
 6  https://css-tricks.com/aspect-ratio-boxes/
 7  */
 8  width: 100%;
 9  height: 0;
10  padding-bottom: 56.25%;
11  position: relative;
12  background: black;
13}
14/* style lazy loaded youtube video placeholder image */
15.lazyt img.placeholder {
16  /* scale responsively with wrapper */
17  position: absolute;
18  top: 0;
19  left: 0;
20  width: 100%;
21  height: 100%;
22  margin: 0;
23  padding: 0;
24  /* let black bars be cropped */
25  object-fit: cover;
26}

Which gives us:

That’s more like it!

Now the placeholder preview image is all set, but it still doesn’t look like a video.

An actual video embed currently looks like:

And has the key noticeable behaviors:

We can get a more obvious video placeholder by at least mimicing the play button.

It turns out this button is just an inline SVG with some CSS for the color change, which we can emulate like so:

Updated HTML:

1<!--wrapper div-->
2<div class="video-wrapper lazyt">
3  <!-- the placeholder image -->
4  <img class="placeholder" src="https://i3.ytimg.com/vi/BPVO2mcfjJk/maxresdefault.jpg">
5  <!-- placeholder play button -->
6  <button class="placeholder playbutton" aria-label="Play"><svg height="100%" version="1.1" viewBox="0 0 68 48" width="100%"><path class="ytp-large-play-button-bg" d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z" fill="#f00"></path><path d="M 45,24 27,14 27,34" fill="#fff"></path></svg></button>
7</div>

Additional CSS:

 1.lazyt.video-wrapper {
 2  /* so we can absolute position the button */
 3  position: relative;
 4  display: inline-block;
 5}
 6.lazyt .placeholder {
 7  /* indicate clickability */
 8  cursor: pointer;
 9}
10.lazyt .placeholder.playbutton {
11  /* center button in video */
12  position: absolute;
13  top: 50%;
14  left: 50%;
15  width: 68px;
16  height: 48px;
17  margin-left: -34px;
18  margin-top: -24px;
19  padding: 0;
20  /* remove any button styling */
21  background: transparent;
22  color: transparent;
23  user-select: none;
24  border: none;
25  outline: none;
26}
27/* mimic transparent gray => solid red on hover */
28.lazyt .placeholder.playbutton .ytp-large-play-button-bg {
29  opacity: .8;
30  fill: #111;
31  -moz-transition: opacity .25s cubic-bezier(0.0,0.0,0.2,1);
32  -webkit-transition: opacity .25s cubic-bezier(0.0,0.0,0.2,1);
33  transition: opacity .25s cubic-bezier(0.0,0.0,0.2,1);
34}
35.lazyt:hover .placeholder .ytp-large-play-button-bg {
36  opacity: 1;
37  fill: #f00;
38  -moz-transition: opacity .1s cubic-bezier(0.0,0.0,0.2,1);
39  -webkit-transition: opacity .1s cubic-bezier(0.0,0.0,0.2,1);
40  transition: opacity .1s cubic-bezier(0.0,0.0,0.2,1);
41}

Which gives us:

That’s starting to look better.

We could continue trying to mimic every little visual detail, but those are likely to change on us in the future anyhow, and it turns out later that we may want users to be able to recognize that the video is not fully loaded yet, so we’ll leave the placeholder here.

This is very lightweight, with just a small amount of iniline HTML / CSS / SVG, and one relatively small thumnail image. We’ve only taken a dependency on the YouTube thumnail server.

Actual Lazy Loading

OK, so now we have a youtube-video-embed-esque preview, now how do we load in the video when the user clicks on it / “presses play”?

For this part, we are going to use javascript. My reasoning for this is that actually playing youtube videos does require javascript, whereas creating a preview should not / having the preview allows us to retain our layout and indicate to users that they might want to enable javascript to allow YouTube to function.

To take action when the user clicks on the video we’ll set an onclick handler on the video wrapper div, like: <div class="lazyt video-wrapper" onclick="playYT(this)">

Now we just need to make the video load. To do this we could just inject an <iframe> in place of the placeholders on click. This approach works pretty well actually. If we set the autoplay=1 attribute on the embed url we can even get the video to play as epected, but not in all browsers.

To support autoplay in more browsers, we can instead use the YouTube IFrame Player API, loading the API itself once the user clicks on any video. The iframe player API supports controlling the embeded player from outside the iframe, allowing us to trigger playback in most browsers.

Based on the API examples, I hacked up this:

 1/* lazy loading youtube */
 2// WARNING: I am an infrastructure developer by trade.
 3// This javascript is probably rather terrible ...
 4// It works though 🤷‍♂️
 5var ytInjected = false;
 6function playYT(wrapper, videoID) {
 7    // we don't need to load the video again
 8    wrapper.onclick = null;
 9    // inject youtube API if we haven't already
10    var preYTInjected = ytInjected
11    if (!preYTInjected) {
12        var tag = document.createElement('script');
13        tag.src = "https://www.youtube.com/iframe_api";
14        var firstScriptTag = document.getElementsByTagName('script')[0];
15        firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
16        ytInjected = true;
17    }
18    // inject the video, replacing the placeholder
19    var videoDiv = wrapper.children[0];
20    // TODO: this probably could be cleaner?
21    wrapper.children[1].remove();
22    wrapper.children[1].remove();
23    videoDiv.classList = "";
24    if (!preYTInjected) {
25        // the API will call this when it's loaded
26        window.onYouTubeIframeAPIReady = function () {
27            createVid(videoDiv, videoID);
28        }
29    } else {
30        // TODO: what if it's injected already but still loading? 🤔
31        createVid(videoDiv, videoID);
32    }
33}
34
35function createVid(videoDiv, videoID) {
36    var player = new YT.Player(videoDiv, {
37        videoId: videoID,
38        // setting origin is helpful for local development IIRC
39        playerVars: { 'autoplay': 1, 'origin': window.location.href },
40        width: "630px",
41        height: "355px",
42        // autoplay on ready if possible
43        // unintuitively this works in places where the playerVar does not
44        events: { 'onReady': function (e) { e.target.playVideo(); }, },
45    });
46}

In the CSS we need to add rules scaling the <iframe> we’re injecting:

1.video-wrapper iframe {
2  position: absolute;
3  top: 0;
4  left: 0;
5  width: 100%;
6  height: 100%;
7  z-index: 2;
8}

In the HTML we just need to add an onclick="playYT(this, 'BPVO2mcfjJk')" attribute to the <div class="lazyt videowrapper"> element.

And now finally we have:

And one with a maxresdefault thumb:

Pretty good!

Optimization

In this state the videos work pretty well, and use a fraction of the bandwidth, the maxresdefault.jpg are something like 120 kB versus 820+ for the whole embed, not bad.

We can improve further by using the more efficient webp thumbnails when in compatible browsers (basically anything but Safari or IE 2), or you could consider only using the “hq” images.

The webp images are instead at: i3.ytimg.com/vi_webp/$video_id/$quality.webp

To deal with browser compatibility we can use the <picture> element, wrapping our existing <img> and adding <source> tags for webp and jpeg thumbnails. In compatible browsers the sources will be preferred first to last by which format is supported. In older browsers the <picture> wll be ignored and in all browsers the <img> tag will be used for display.

The updated embed is then like:

 1<div class="video-wrapper lazyt">
 2  <!-- the placeholder image -->
 3  <picture>
 4    <source type="image/webp" srcset="https://i3.ytimg.com/vi_webp/BPVO2mcfjJkmaxresdefault.webp">
 5    <source type="image/jpeg" srcset="https://i3.ytimg.com/vi/BPVO2mcfjJk/maxresdefault.jpg">
 6    <img class="placeholder" src="https://i3.ytimg.com/vi/BPVO2mcfjJk/maxresdefault.jpg" alt="click to play the video" type="image/jpeg">
 7  </picture>
 8  <!-- placeholder play button -->
 9  <button class="placeholder playbutton" aria-label="Play"><svg height="100%" version="1.1" viewBox="0 0 68 48" width="100%"><path class="ytp-large-play-button-bg" d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z" fill="#f00"></path><path d="M 45,24 27,14 27,34" fill="#fff"></path></svg></button>
10</div>

These images are approxmately 50% smaller in my limited sampling, our first example drops from ~120 kB to ~60 kB.

Wrap Up

I’m relatively happy with this approach, and will probably continue to refine it and use it selectively.

There are a few remaining drawbacks:

  1. Lazy loading the video takes some small additional time upon clicking play.

  2. There’s no way for the user to open the video on youtube.com without starting playback.

  3. On iOS the video will not play after clicking the fake play button, the user must click the real play button after the video loads following clicking the fake one.

The first of these is largely ignorable / a small cost to pay for faster page loads.

The second is solvable by improving the placeholder design to add a video title / link similar to the real embedded video.

The last of these unfortunately does not seem to have a work-around, it relates to how iOS / safari blocks auto-playinig videos. On pretty much all other browsers this implementation seems to work as expected.

Depending on your use-case, that last flaw may be a bit of a deal-breaker. Currently I think this makes the most sense on pages with multiple videos, which may not all be viewed by the average user.

It is also obviously painful to replace a small embed snippet with this when writing pages by hand - I chose to implement a hugo shortcode (~templated snippet) to avoid this. Similar functionality should be available in most site generators.

I’m also pretty certain that the javascript used here is not idiomatic, but the concept and functionality should hold 🙃


  1. “Avoid enormous network payloads” web.dev/total-byte-weight/ ↩︎

  2. Support from all browsers except IE and Safari based on caniuse.com/webp ↩︎