Lazy load images and iframes in WordPress without a plugin

Lazy loading defers the loading of off-screen images and iframes until they are about to enter the viewport, reducing initial page weight and speeding up the largest contentful paint (LCP) metric. WordPress has supported the native loading="lazy" attribute on images and iframes since version 5.5, added automatically via the wp_lazy_loading_enabled filter and applied by the wp_filter_content_tags() function that processes post content. For most WordPress sites this means lazy loading works out of the box for images inserted through the block editor or the classic editor. However, images added through theme templates, hardcoded in widget output, or rendered by page builder shortcodes may not pass through wp_filter_content_tags() and therefore miss the attribute. Additionally, the native browser lazy loading uses a fixed distance threshold that varies by browser and connection speed, which may not match your design requirements. The Intersection Observer API provides a JavaScript-based fallback that gives you precise control over the trigger point: you store the real image URL in a data-src attribute and swap it into src when the image enters the viewport. This approach also supports a blur-up effect where a tiny low-quality placeholder image is shown first and replaced with the full image on load. For iframes — particularly YouTube embeds — the native loading="lazy" attribute works in Chrome and is being rolled out in other browsers. A lightweight facade approach renders a thumbnail and play button image instead of the full iframe until the user clicks, eliminating the heavy YouTube embed scripts from the initial load entirely. Review this alongside the WebP image guide and the Nginx cache guide for a complete page speed optimisation strategy.

Problem: Images and iframes below the fold load on every page view regardless of whether the user scrolls to them, increasing initial page weight and slowing down LCP.

Solution: Ensure loading="lazy" is applied to all content images via WordPress filters, and add an Intersection Observer fallback for dynamically rendered images:

// Ensure lazy loading is enabled (it is by default since WP 5.5,
// but plugins may disable it — re-enable here)
add_filter( 'wp_lazy_loading_enabled', '__return_true' );

// Apply lazy loading to images in widget and template output
// that does not pass through the block editor pipeline
add_filter( 'the_content',       'ha_add_lazy_to_images', 99 );
add_filter( 'widget_text_content', 'ha_add_lazy_to_images', 99 );

function ha_add_lazy_to_images( $content ) {
    // Only process if the tag doesn't already have loading attribute
    return preg_replace_callback(
        '/(<img[^>]*?)(>)/i',
        function ( $matches ) {
            if ( strpos( $matches[1], 'loading=' ) === false ) {
                return $matches[1] . ' loading="lazy"' . $matches[2];
            }
            return $matches[0];
        },
        $content
    );
}

// Skip lazy loading for the first image (LCP candidate — should load eagerly)
add_filter( 'wp_lazy_loading_enabled', 'ha_skip_first_image_lazy', 10, 3 );

function ha_skip_first_image_lazy( $default, $tag_name, $context ) {
    static $first_img_done = false;
    if ( $tag_name === 'img' && $context === 'the_content' && ! $first_img_done ) {
        $first_img_done = true;
        return false; // Do NOT lazy-load the first content image
    }
    return $default;
}

// Intersection Observer lazy loader for images with data-src
// Use when you need a custom threshold or a blur-up effect
(function () {
    if ( ! ( 'IntersectionObserver' in window ) ) {
        // Fallback: load all images immediately
        document.querySelectorAll( 'img[data-src]' ).forEach( function ( img ) {
            img.src = img.dataset.src;
        } );
        return;
    }

    const observer = new IntersectionObserver( function ( entries ) {
        entries.forEach( function ( entry ) {
            if ( ! entry.isIntersecting ) return;
            const img = entry.target;
            img.src = img.dataset.src;
            img.classList.add( 'is-loaded' );
            observer.unobserve( img );
        } );
    }, { rootMargin: '200px 0px' } ); // Start loading 200px before viewport

    document.querySelectorAll( 'img[data-src]' ).forEach( function ( img ) {
        observer.observe( img );
    } );
}());

NOTE: Never apply loading="lazy" to the hero image or the first above-the-fold image — this delays the LCP element and hurts Core Web Vitals scores. The PHP filter above skips the first content image for this reason. For YouTube iframes, use the lite-youtube-embed web component or a simple facade: replace the iframe with a clickable thumbnail that loads the actual iframe on click, reducing the initial page payload by several hundred kilobytes per embed. Test lazy loading behaviour with Chrome DevTools Network tab by throttling to “Slow 3G” and scrolling down to verify images load just before they enter the viewport.