Skip to content

Video Lazy Loading — Deferring Video Content Until Interaction

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Video Lazy Loading. We cover key concepts, practical examples, and best practices to help you master this topic.

Video lazy loading defers video content until the user interacts, reducing initial page weight and improving Largest Contentful Paint scores.

What You'll Learn

By the end of this tutorial, you'll understand how video files impact page performance, how to use the loading attribute and poster images, how to replace video sources with lightweight previews, and how to implement play-on-demand patterns.

Why It Matters

Video files are the heaviest resources on the web. A single 30-second MP4 can be 5-15MB — larger than an entire page of text, images, and scripts combined. Autoplaying videos consume bandwidth and delay LCP even when not visible. Lazy loading videos ensures they download only when the user chooses to watch them.

Real-World Use

A product landing page includes three demo videos. Instead of loading all three on page load, each video shows a lightweight poster image (50KB webp). When the user clicks a video card, the poster is replaced with an embedded player that streams the video. Initial page load drops from 18MB to 800KB, and LCP improves from 4.5s to 1.2s.

Video Loading Strategies

graph LR
    A[Video Loading] --> B[Poster + preload=none
Recommended] A --> C[loading=lazy attribute
Chrome/Edge only] A --> D[Intersection-based
Cross-browser] A --> E[Click-to-play
Lightest approach] A --> F[Streaming / HLS
Adaptive bitrate] B --> G[Poster image shown
No video data loaded] C --> H[Video loads when
near viewport] D --> I[Load src when
video element visible] E --> J[Load only on
user click] F --> K[Load initial segment
~2-5 seconds] style B fill:#27ae60,color:#fff style E fill:#27ae60,color:#fff style C fill:#4a90d9,color:#fff
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Video Lazy Loading — Poster</title>
    <style>
        .video-container {
            position: relative;
            width: 100%;
            max-width: 800px;
            margin: 20px auto;
            background: #000;
            border-radius: 8px;
            overflow: hidden;
        }

        .video-container video {
            width: 100%;
            display: block;
        }

        .play-button {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 80px;
            height: 80px;
            background: rgba(255, 255, 255, 0.9);
            border: none;
            border-radius: 50%;
            cursor: pointer;
            font-size: 32px;
            color: #1a1a2e;
            transition: transform 0.2s, background 0.2s;
            z-index: 2;
        }

        .play-button:hover {
            transform: translate(-50%, -50%) scale(1.1);
            background: #ffffff;
        }
    </style>
</head>
<body>
    <!-- preload=none: Don't load any video data until play is clicked -->
    <!-- poster: Lightweight preview image shown instead -->
    <div class="video-container">
        <video
            controls
            preload="none"
            poster="/videos/demo-poster.webp"
            width="800"
            height="450"
            aria-label="Product demo video"
        >
            <source src="/videos/product-demo.mp4" type="video/mp4">
            <p>Your browser does not support video playback.</p>
        </video>
        <button class="play-button" aria-label="Play video">▶</button>
    </div>

    <script>
        // Click-to-play: load video only when user clicks
        document.querySelector('.play-button').addEventListener('click', function() {
            const video = this.previousElementSibling;
            video.preload = 'auto';
            video.load();
            video.play();
            this.hidden = true;
        });

        // Detect when native controls show (user clicked browser controls)
        document.querySelector('video').addEventListener('play', function() {
            const button = this.nextElementSibling;
            if (button) button.hidden = true;
        });
    </script>
</body>
</html>

Native loading=lazy Attribute

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Video loading=lazy Demo</title>
</head>
<body>
    <!-- Video 1: Above the fold — load normally -->
    <video controls width="800" height="450" poster="/videos/intro-poster.webp">
        <source src="/videos/intro.mp4" type="video/mp4">
    </video>

    <!-- Video 2: Below the fold — lazy load -->
    <video
        controls
        loading="lazy"
        width="800"
        height="450"
        poster="/videos/demo-poster.webp"
    >
        <source src="/videos/product-demo.mp4" type="video/mp4">
    </video>

    <!-- Video 3: Far below the fold — lazy load -->
    <video
        controls
        loading="lazy"
        width="800"
        height="450"
        poster="/videos/testimonial-poster.webp"
    >
        <source src="/videos/testimonial.mp4" type="video/mp4">
    </video>

    <!-- Note: loading=lazy is supported in Chrome/Edge 79+ -->
    <!-- Falls back to normal loading in other browsers -->
</body>
</html>

Intersection Observer Video Loading

// utils/video-loader.js — Lazy load videos via Intersection Observer
class VideoLazyLoader {
    constructor(options = {}) {
        this.options = {
            rootMargin: options.rootMargin || '200px',
            threshold: options.threshold || 0,
            ...options
        };

        this.videos = new Map();
        this.observer = null;
        this.init();
    }

    init() {
        if ('IntersectionObserver' in window) {
            this.observer = new IntersectionObserver(
                (entries) => this.handleIntersection(entries),
                {
                    rootMargin: this.options.rootMargin,
                    threshold: this.options.threshold
                }
            );

            // Observe all videos with data-src
            document.querySelectorAll('video[data-src]').forEach(video => {
                this.observer.observe(video);
            });
        } else {
            // Fallback: load all videos immediately
            document.querySelectorAll('video[data-src]').forEach(video => {
                this.loadVideo(video);
            });
        }
    }

    handleIntersection(entries) {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                const video = entry.target;
                this.loadVideo(video);
                this.observer.unobserve(video);
            }
        });
    }

    loadVideo(video) {
        const src = video.dataset.src;
        if (!src || this.videos.has(video)) return;

        this.videos.set(video, true);

        // Set the actual video source
        video.src = src;

        // Load metadata first, then decide if we should preload more
        video.preload = 'metadata';

        // If the video is fully visible, start loading
        if (this.isFullyVisible(video)) {
            video.preload = 'auto';
        }

        video.load();
        console.log(`Video loaded: ${src}`);
    }

    isFullyVisible(video) {
        const rect = video.getBoundingClientRect();
        return (
            rect.top >= 0 &&
            rect.left >= 0 &&
            rect.bottom <= window.innerHeight &&
            rect.right <= window.innerWidth
        );
    }

    // Load video on user click (play-on-demand)
    static clickToPlay(container) {
        const video = container.querySelector('video');
        const playButton = container.querySelector('.play-button');

        if (!video || !playButton) return;

        playButton.addEventListener('click', () => {
            const src = video.dataset.src;
            if (src) {
                video.src = src;
                delete video.dataset.src;
            }
            video.load();
            video.play();
            playButton.hidden = true;
        });

        // Show play button
        playButton.hidden = false;
    }
}

// Usage
const videoLoader = new VideoLazyLoader({ rootMargin: '300px' });

// Initialize click-to-play for all video containers
document.querySelectorAll('.video-container').forEach(container => {
    VideoLazyLoader.clickToPlay(container);
});

YouTube Embed Lazy Loading

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Lazy Loading YouTube Embeds</title>
    <style>
        .youtube-lazy {
            position: relative;
            width: 100%;
            max-width: 800px;
            height: 0;
            padding-bottom: 56.25%;
            background: #000;
            border-radius: 8px;
            overflow: hidden;
            cursor: pointer;
        }

        .youtube-lazy img {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            object-fit: cover;
        }

        .youtube-play-btn {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 68px;
            height: 48px;
            background: rgba(0, 0, 0, 0.8);
            border: none;
            border-radius: 12px;
            cursor: pointer;
            z-index: 2;
        }

        .youtube-play-btn::after {
            content: '';
            display: block;
            border-left: 20px solid #fff;
            border-top: 12px solid transparent;
            border-bottom: 12px solid transparent;
            margin-left: 26px;
            margin-top: 12px;
        }

        .youtube-play-btn:hover {
            background: #ff0000;
        }
    </style>
</head>
<body>
    <!-- Lazy YouTube embed using poster image -->
    <div class="youtube-lazy" data-video-id="dQw4w9WgXcQ" role="button" aria-label="Play video" tabindex="0">
        <!-- Use YouTube's own thumbnail as poster -->
        <img
            src="https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
            alt="Video thumbnail"
            loading="lazy"
            width="480"
            height="360"
        >
        <div class="youtube-play-btn" aria-hidden="true"></div>
    </div>

    <script>
        // Replace poster with iframe on click
        document.querySelectorAll('.youtube-lazy').forEach(container => {
            const loadVideo = () => {
                const videoId = container.dataset.videoId;
                const iframe = document.createElement('iframe');
                iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`;
                iframe.width = '100%';
                iframe.height = '100%';
                iframe.style.position = 'absolute';
                iframe.style.top = '0';
                iframe.style.left = '0';
                iframe.style.border = '0';
                iframe.allow = 'accelerometer; autoplay; encrypted-media; gyroscope';
                iframe.allowFullscreen = true;
                iframe.loading = 'lazy';
                container.innerHTML = '';
                container.appendChild(iframe);
                container.classList.add('loaded');
            };

            container.addEventListener('click', loadVideo);
            container.addEventListener('keydown', (e) => {
                if (e.key === 'Enter' || e.key === ' ') {
                    e.preventDefault();
                    loadVideo();
                }
            });
        });
    </script>
</body>
</html>

Performance Measurement

// Measure video lazy loading impact
async function measureVideoPerformance() {
    const results = {
        totalVideos: 0,
        videosLoadedOnLoad: 0,
        totalVideoSize: 0,
        lcpImpact: null
    };

    // Count videos
    const videos = document.querySelectorAll('video');
    results.totalVideos = videos.length;

    // Check which videos loaded data on page load
    videos.forEach(video => {
        if (video.readyState > 0) {
            results.videosLoadedOnLoad++;
        }
    });

    // Estimate video size from network entries
    const videoResources = performance.getEntriesByType('resource')
        .filter(r => r.name.includes('.mp4') || r.name.includes('.webm'));

    results.totalVideoSize = videoResources.reduce(
        (total, r) => total + (r.transferSize || 0), 0
    );

    // Check LCP
    const lcpEntry = performance.getEntriesByType('largest-contentful-paint');
    if (lcpEntry.length > 0) {
        results.lcpImpact = `${lcpEntry[0].renderTime.toFixed(0)}ms`;
    }

    results.recommendation = results.totalVideoSize > 1000000
        ? 'Videos are >1MB on load. Use poster + preload=none for all non-essential videos.'
        : 'Video loading is well optimized';

    return results;
}

Common Mistakes

  1. Not using a poster image. Without a poster, the video element shows a black rectangle until the first frame loads. Always provide a lightweight poster image (under 100KB) for a better visual experience.
  2. Using autoplay for above-fold videos. Autoplay videos compete with LCP elements, consume bandwidth, and annoy users if they have audio. Use poster + click-to-play for above-fold videos.
  3. Not setting dimensions. Videos without explicit width and height cause Cumulative Layout Shift when they load. Always set width and height, or use padding-bottom percentage trick for responsive containers.
  4. Loading all video sources in multiple formats. Each tag triggers a request to check format support. Use a single MP4 (widest support) or detect support with JavaScript before adding sources.
  5. Forgetting mobile data users. A 10MB video on a 3G connection takes 20+ seconds to load. Always serve compressed videos (h.264/h.265), consider adaptive streaming, and show a loading indicator.

Practice Questions

  1. What does preload=none do and when should you use it?
  2. How does the poster attribute improve perceived performance?
  3. What browsers support the loading=lazy attribute on videos?
  4. How can Intersection Observer improve video loading compared to native loading=lazy?
  5. Why is click-to-play the best approach for above-fold videos?

Challenge: Build a product gallery page with 5+ demo videos. Implement a tiered Strategy: click-to-play for the main feature video, Intersection Observer for inline demo videos, and poster-only for testimonial videos. Measure initial page weight with and without lazy loading, and track how many videos load on page load vs on interaction.

FAQ

Can I use loading=lazy on iframe embeds (YouTube, Vimeo)?

No, but you can achieve the same effect by replacing a poster image with an iframe on click. This is the recommended approach for YouTube embeds — it prevents the embed from loading any YouTube resources until the user clicks.

Does preload=none affect analytics or video tracking?

No. The video metadata (duration, dimensions) is not available until the user initiates playback. Use the play event to trigger analytics tracking instead of page load.

What about video ads?

Ad networks require their own tracking. Lazy loading videos that contain ads may delay ad impressions. Work with your ad provider to determine the best lazy loading approach for ad-supported videos.

Should I use WebM or MP4 for lazy loaded videos?

MP4 (h.264) has the widest browser support. If bandwidth is a concern, serve both with a element. Start with MP4 as the default and add WebM as an enhancement.

How do I maintain aspect ratio with lazy loaded videos?

Use the padding-bottom trick: wrap the video in a container with padding-bottom: 56.25% (for 16:9) and position the video absolutely inside. This prevents layout shift regardless of when the video loads.

Mini Project

Build a video gallery with mixed lazy loading strategies: a hero video with click-to-play and poster, inline demo videos with Intersection Observer, YouTube embeds with poster-replacement, and a performance dashboard showing bytes loaded on page load vs on demand. Compare the initial load weight and LCP scores.

What's Next

You've mastered video lazy loading. Next, learn about Lazy Loading SEO to understand the SEO implications of deferred content loading.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro