Leereamsnyder.com Posts logo

Leereamsnyder.com Posts

Subscribe
Archives
September 1, 2023

Lazy-loading videos with Svelte actions | leereamsnyder.com

(Just in case the full article gets cut off, you can read this post online here: https://www.leereamsnyder.com/blog/lazy-loading-videos-svelte-action)

Or: fun with Intersection and Mutation Observers


Some of my posts, in particular lengthy video game guides like my Hades 32-heat guide or my big collection of Zelda: Tears of the Kingdom tips—use the <video /> tag to show inline snippets of gameplay.

It’s nice to break up big chunks of text or demonstrate the finer points with a video. But because videos can be bandwidth hogs, I’ve used a couple of different patterns over the years to respect my visitor’s data usage.

Here’s where I’ve landed today.

The video markup

Here’s the full markup pattern I use for videos:

<video class="lazy" width="1280" height="720" muted loop playsinline controls preload="none">
  <source src="/path/to/video.mp4" type="video/mp4" />

  Your browser does not support the video tag or something has gone wrong with loading the video.
</video>

Bunch of things to call out here:

  • I don’t use the autoplay attribute. I could add it to make the video something closer to an animated GIF, but I’d rather give my visitor control over starting playback. Oh, also, autoplay trumps any preload setting and forces the browser to load the video.
  • controls makes sure you can control playback.
  • playsinline will play the video in place in the page instead of the default behavior (in mobile browsers) of taking over the full screen during playback.
  • muted will initially mute the video’s audio track. Nobody wants videos to start playing and blasting full-volume sound at you except for users of Instagram, according to Instagram, apparently.
  • loop will replay the video automatically, like an animated GIF.
  • The height and width attributes make sure the video has the correct aspect ratio, so it won’t change size and shift the page around when data actually does load in.
  • The class="lazy" gives me a hook to target videos with my scripts.

The preload attribute set to "none" is the most important bit. Without it, or even with the Goldilocks-sounding "metadata" value, I found that Firefox and Safari often loaded short videos in full in advance, even if the video was waaaay down the page and you might not ever see it.

You can solve this problem with images with loading="lazy", but videos don’t have anything like that.

The top Google result on lazy-loading videos from web.dev suggests sending incomplete video markup (you use something non-functional like data-src instead of src for the video urls) to prevent anything preloading, then fixing the markup with JavaScript.

I used this pattern for a while. And it works great! Unless, like I do, you push that incomplete markup into something like an RSS feed or an email newsletter where I won’t have access to JavaScript to fix things. Or if you want the videos to still sorta work if my scripts fail for whatever reason.

With functional HTML, using preload="none" was the only reliable way to to make sure all browsers load nothing. The drawback is nothing really means nothing: the video tag is an empty box with a play icon, no preview image, no playback details like the duration. You have to start playback to initiate loading.

We’ll improve this situation with scripts.

The JavaScript (as a Svelte action)

Because I build my site with Svelte, my current implementation uses a Svelte action, a function that runs when an element is mounted and receives that element as an argument.

You can use actions to tweak the behavior of existing elements; the official Svelte tutorial on actions calls out lazy-loading as a possible use case.

Here’s the action code that’s in use on my site today:

// actions/lazyLoadVideos.js

/** @type {import('svelte/action').Action}  */
export default function lazyLoadVideos(node) {
  const supported = 'IntersectionObserver' in window && 'MutationObserver' in window
  if (!supported) return

  const io = new IntersectionObserver((entries, observer) => {
    for (const { isIntersecting, target: video } of entries) {
      if (isIntersecting && video instanceof HTMLVideoElement) {
        video.load()
        video.classList.remove('lazy')
        observer.unobserve(video)
      }
    }
  })

  const mo = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      for (const removedNode of Array.from(mutation.removedNodes)) {
        if (removedNode instanceof HTMLVideoElement && removedNode.classList.contains('lazy')) {
          io.unobserve(removedNode)
        }
      }
      for (const addedNode of Array.from(mutation.addedNodes)) {
        if (addedNode instanceof HTMLVideoElement && addedNode.classList.contains('lazy')) {
          io.observe(addedNode)
        }
      }
    }
  })

  // initialization for the IntersectionObserver (first load)
  const initialLazyVideos = Array.from(node.querySelectorAll('video.lazy'))
  for (const lazyVideo of initialLazyVideos) {
    io.observe(lazyVideo)
  }

  mo.observe(node, {
    childList: true,
    subtree: false,
  })

  return {
    destroy() {
      io.disconnect()
      mo.disconnect()
    },
  }
}

The core idea—using an IntersectionObserver to detect when a video enters the screen—comes straight from the web.dev article.

But because I already have functional markup, my callback for that IntersectionObserver—what runs when videos that you .observe() enter or leave the screen—is simpler:

const io = new IntersectionObserver((entries, observer) => {
  for (const { isIntersecting, target: video } of entries) {
    if (isIntersecting && video instanceof HTMLVideoElement) {
      video.load()
      video.classList.remove('lazy')
      observer.unobserve(video)
    }
  }
})

We only call the video’s load() method to override the preload="none" and start loading the video. Depending on your needs, you could do other things: perhaps set video.autoplay = true to GIF-ify the video.

You don’t strictly need the instanceof HTMLVideoElements checks, but if you’re using TypeScript, it’ll appease the compiler that the video element will have a load method.

When the action fires up, we check for any existing videos on the page and have the IntersectionObserver start, uh, observing them:

const initialLazyVideos = Array.from(node.querySelectorAll('video.lazy'))
for (const lazyVideo of initialLazyVideos) {
  io.observe(lazyVideo)
}

If your content on the page will never change, you’re done.

However, in my case, the element in question stays mounted if you navigate between different posts. So we need to detect if videos have been added or removed after we set everything up.

The solution I landed on is a MutationObserver, a standard JavaScript API that tells you when the DOM for an element has changed:

const mo = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const removedNode of Array.from(mutation.removedNodes)) {
      if (removedNode instanceof HTMLVideoElement && removedNode.classList.contains('lazy')) {
        io.unobserve(removedNode)
      }
    }
    for (const addedNode of Array.from(mutation.addedNodes)) {
      if (addedNode instanceof HTMLVideoElement && addedNode.classList.contains('lazy')) {
        io.observe(addedNode)
      }
    }
  }
})

mo.observe(node, {
  childList: true,
  subtree: false,
})

Our mutation callback runs when elements are added or removed from the parent element node. If it’s a lazy video, we add or remove it from the IntersectionObserver’s list of observed elements. So when the page’s content changes we’ll handle any new videos.

On my site, videos are direct children of the parent element, but if they are deeper in the tree you can have the MutationObserver track that with the subtree: true option.

Add this action to an existing DOM element in Svelte like so:

<script>
  import lazyLoadVideos from './actions/lazyLoadVideos.js'
</script>

<div use:lazyLoadVideos>
  <!-- your content with videos -->
</div>

Assuming I got this right and it all works, here, give it a shot:

Your browser does not support the video tag or something has gone wrong with loading the video. Try it on Imgur

Don't miss what's next. Subscribe to Leereamsnyder.com Posts:
GitHub X YouTube Facebook LinkedIn Instagram
Powered by Buttondown, the easiest way to start and grow your newsletter.