Lazy Loading Videos: The Technique Most Developers Get Wrong
Setting preload='none' isn't lazy loading. Here's how to properly defer video downloads using Intersection Observer, facade patterns, and the right preload strategy.
You've heard the advice: lazy load your videos to improve page speed. So you add preload="none" to your <video> tags and call it a day.
Except that's not lazy loading. That's just telling the browser not to preload the video. The <video> element is still in the DOM, the browser still makes a connection to resolve the source URL, and on some browsers, it still downloads the first few bytes to sniff the format.
Real lazy loading means the video doesn't exist in the DOM until the user is about to see it. Here's how to do it properly.
The preload attribute is not enough
The preload attribute has three values:
| Value | What it does | What it doesn't do |
|---|---|---|
auto | Downloads the full video as soon as possible | - |
metadata | Downloads only headers and metadata (~100KB-1MB) | Doesn't prevent the initial connection |
none | Hints that the browser shouldn't preload | Doesn't guarantee zero download; browser may ignore it |
The critical word is hint. The preload attribute is advisory. Browsers can and do ignore it. Chrome on Android, for example, may still download metadata even with preload="none" to determine video dimensions.
For a page with 3-4 videos below the fold, even preload="none" results in multiple unnecessary HTTP connections at page load.
True lazy loading with Intersection Observer
Intersection Observer lets you detect when an element enters the viewport. Combined with data-src instead of src, you can defer all video loading until the user scrolls to it:
<video autoplay muted loop playsinline
preload="none"
poster="demo-poster.webp"
width="1280" height="720">
<source data-src="demo.mp4" type="video/mp4">
</video>document.addEventListener('DOMContentLoaded', () => {
const lazyVideos = document.querySelectorAll('video[preload="none"]');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const video = entry.target;
// Swap data-src to src on all sources
video.querySelectorAll('source[data-src]').forEach(source => {
source.src = source.dataset.src;
source.removeAttribute('data-src');
});
// Trigger load
video.load();
observer.unobserve(video);
}
});
}, {
rootMargin: '200px' // Start loading 200px before it enters the viewport
});
lazyVideos.forEach(video => observer.observe(video));
});The rootMargin: '200px' is important. It starts loading the video 200px before it scrolls into view, giving the browser a head start so the video is ready by the time the user sees it. Adjust this value based on how fast your videos need to start playing.
The iframe problem: YouTube and Vimeo embeds
Embedded videos from YouTube or Vimeo present a different challenge. A standard YouTube embed looks like this:
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"
width="560" height="315" frameborder="0"
allowfullscreen></iframe>That single iframe triggers:
- ~600KB of JavaScript downloaded and executed
- Multiple HTTP connections to YouTube's servers
- Cookie tracking (unless you use
youtube-nocookie.com) - Render-blocking resources inside the iframe
Multiply this by 3-4 embeds on a page and you've added 2-3MB of overhead and several seconds to your load time before the user has even clicked play.
The facade pattern
A facade (or "click-to-load") pattern replaces the heavy iframe with a lightweight placeholder. The iframe only loads when the user clicks play:
<div class="video-facade"
data-video-id="dQw4w9WgXcQ"
style="background-image: url('https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg')">
<button aria-label="Play video">
<svg><!-- play icon --></svg>
</button>
</div>document.querySelectorAll('.video-facade').forEach(facade => {
facade.addEventListener('click', () => {
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube-nocookie.com/embed/${facade.dataset.videoId}?autoplay=1`;
iframe.width = 560;
iframe.height = 315;
iframe.allow = 'autoplay; encrypted-media';
iframe.allowFullscreen = true;
facade.replaceWith(iframe);
});
});The result: instead of loading 600KB+ of YouTube JS on page load, you load a single thumbnail image (~15-30KB). The full embed only loads when the user explicitly wants to watch the video.
lite-youtube-embed: The production-ready facade
Writing your own facade works, but lite-youtube-embed by Paul Irish is the battle-tested solution. It's a web component that handles all the edge cases:
<lite-youtube videoid="dQw4w9WgXcQ"
playlabel="Play: Video Title">
</lite-youtube>What it handles for you:
- Responsive sizing with correct aspect ratio
- Thumbnail loading with proper resolution selection
- Warm connections: on hover, it preconnects to YouTube's servers so the iframe loads faster when clicked
- Accessibility with proper ARIA labels and keyboard support
youtube-nocookie.comby default for privacy
The entire component is under 1KB. Compare that to the 600KB+ a raw YouTube iframe loads.
Preconnect hints for faster video loading
Whether you're lazy loading self-hosted video or using a facade pattern for embeds, preconnect hints can speed things up. Add these to your <head>:
For self-hosted video:
<link rel="preconnect" href="https://your-cdn.com">For YouTube embeds (only if you know the user will likely click play):
<link rel="preconnect" href="https://www.youtube-nocookie.com">
<link rel="preconnect" href="https://i.ytimg.com">Don't add preconnect hints for every possible video source. Each hint opens a connection that consumes resources. Only preconnect to origins you're confident the user will need.
Native lazy loading: what about loading="lazy"?
The loading="lazy" attribute works great for images and iframes:
<!-- This works for iframes -->
<iframe loading="lazy" src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"></iframe>However, loading="lazy" does not work on <video> elements. It's only supported on <img> and <iframe>. For <video> elements, you need the Intersection Observer approach described above.
Using loading="lazy" on YouTube iframes is a quick win if the facade pattern is too much work. It won't save you from the 600KB JS payload, but it will defer it until the user scrolls to the embed.
Putting it all together
Here's a decision tree for lazy loading video:
Self-hosted <video> (autoplay, muted, background/GIF replacement):
- Use
data-srcon<source>elements - Set
preload="none"and include a poster image - Use Intersection Observer with
rootMargin: '200px' - Let autoplay trigger naturally after
video.load()
Self-hosted <video> (click to play, user-initiated):
- Use
preload="metadata"to get dimensions and duration - Show poster image with a play button overlay
- Load full video on click
YouTube/Vimeo embeds:
- Use
lite-youtube-embedor a similar facade component - Show thumbnail + play button (under 30KB total)
- Load the full iframe only on click
- Use
youtube-nocookie.comfor privacy
Multiple videos on one page:
- Lazy load everything below the fold
- Only
preload="auto"for the first visible video (if any) - Consider facade pattern even for self-hosted video if you have 5+ on a page
Measuring the impact
After implementing lazy loading, verify it's working:
- Chrome DevTools Network tab: Filter by "media" and reload the page. Only above-the-fold videos should load initially.
- Lighthouse: Run a performance audit. Your LCP and Total Blocking Time should improve.
- WebPageTest: Check the waterfall chart. Video requests should appear only after scroll events, not at initial page load.
The goal is simple: don't make users download video they haven't asked to see. Everything else is implementation detail.
