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 anypreload
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
andwidth
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: