Lazy Load Images and Background Images in WordPress with Intersection Observer

The Intersection Observer API lets JavaScript react when an element enters or leaves the viewport without polling with setInterval() or attaching a scroll event listener — both of which run on the main thread and cause jank on resource-constrained devices. WordPress adds the loading="lazy" attribute to images automatically since version 5.5, but custom background images set via CSS background-image are outside the reach of the native lazy loading mechanism and require JavaScript. The observer callback fires asynchronously with high precision in a single batch, costing no main-thread time between observations. Storing the real image URL in a data-src attribute and swapping it into the src on intersection is the established pattern for deferring image loading. For background images, storing the URL in data-bg and assigning it to element.style.backgroundImage on intersection achieves the same deferral for hero sections, card thumbnails, and testimonial backgrounds. Setting a small rootMargin such as 200px triggers the load slightly before the element is visible, giving the browser time to download the image so it appears already loaded rather than after a visible delay. After the image is loaded, calling observer.unobserve(element) stops the observation and releases the reference, preventing memory accumulation on long pages. The same API powers infinite scroll, sticky navigation state changes, read-progress bars, and animation triggers — all without scroll event listeners. Adding a CSS class on intersection and driving the animation with CSS transitions keeps the work on the compositor thread, which does not block rendering. The accordion post demonstrates a complementary JavaScript pattern for interactive UI without framework dependencies. The performance post covers the script loading changes that should accompany lazy image loading to achieve the full Lighthouse performance score improvement. Intersection Observer is supported in 97 percent of browsers globally and does not require a polyfill for modern WordPress theme audiences.

Problem: WordPress pages with many off-screen images load all of them on page load, wasting bandwidth and delaying Time to Interactive — especially for CSS background images that the native loading="lazy" attribute does not cover.

Solution: Use the Intersection Observer API to defer loading of <img> tags with data-src and CSS background images with data-bg, triggering each load only when the element approaches the viewport.

(function() {
    if (!('IntersectionObserver' in window)) {
        // Fallback: load all images immediately for unsupported browsers
        document.querySelectorAll('[data-src]').forEach(el => {
            el.src = el.dataset.src;
        });
        document.querySelectorAll('[data-bg]').forEach(el => {
            el.style.backgroundImage = 'url(' + el.dataset.bg + ')';
        });
        return;
    }

    var observer = new IntersectionObserver(function(entries) {
        entries.forEach(function(entry) {
            if (!entry.isIntersecting) return;
            var el = entry.target;

            if (el.dataset.src) {
                el.src = el.dataset.src;
                el.removeAttribute('data-src');
            }

            if (el.dataset.bg) {
                el.style.backgroundImage = 'url(' + el.dataset.bg + ')';
                el.removeAttribute('data-bg');
                el.classList.add('bg-loaded');
            }

            observer.unobserve(el);
        });
    }, {
        rootMargin: '200px 0px',
        threshold:  0
    });

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

NOTE: In the PHP template, output images as <img data-src="..." class="lazy" width="800" height="600" alt="..."> and always include explicit width and height attributes — without them the browser cannot reserve space before the image loads, causing cumulative layout shift (CLS) that hurts Core Web Vitals scores.