Ah yes. Lazy images. Magical content that only loads when you need it. I learned to code with Haskell, so I'm partial to laziness.

Also, my job depends on it. I work with a bunch of talented designers who make very graphic websites. Tens of MB per page, after JPG optimization. Besides making smaller images and using srcsets, we use lazy loading.

The first piece of the puzzle is detecting when the img elements come onscreen. It's not as simple as a listener or callback. We have some hard requirements:

  1. The img element lazy-loading must not cause content to shift.
  2. The img element must load when it comes on screen
  3. The img element will not load if it's offscreen
  4. The img element loads immediately when it's onscreen
  5. The img element must not exhibit poor loading behavior *

* see how ugly either example is? https://www.thewebmaster.com/dev/2016/feb/10/how-progressive-jpegs-can-speed-up-your-website/#jpeg-vs-progressive-jpeg-example

#1 (as well as #2-#4, and maybe even #5) can be satisfied using a placeholder image. Regardless, it's the most important detail in lazy loading images. You could use:

Note that you need to know something about the image before you actually download it - height, and optionally coloring/thumbnails. This is easy if you're working with WordPress or some other media-friendly CMS/framework/whatever.

I've taken the blank SVG route in the past, mostly because I was usually animating rich content onto the screen as it loaded. You can make a blank SVG really easily:

const placeholderImage = (w, h) => 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns='http://www.w3.org/2000/svg' width="${w}" height="${h}"></svg>');

Before we go any further (JSX):

<img className='lazy' data-src='your src' data-srcset='your srcset' data-sizes='your sizes' src={placeholderImage(666, 666)}>

Great. Now, to detect intersection.

function unlazy(img) {
    // we'll implement this further on.
}

// This is a simple predicate to ensure we skip
// processing text nodes, comments, etc.
const isElement = x => x.nodeType === 1;

// This intersection observer will watch only <img /> tags.
let intOb = new IntersectionObserver(entries => {
   entries.filter(e => e.intersectionRatio > 0) // intersected elems
          .map(e => e.target) // get the <img /> element
          .map(unlazy); // unlazy the image
});

// This mutation observer will watch the entire body for
// <img /> elements with the lazy class.
let mutOb = new MutationObserver((mutations, observer) => {
   for (let mut of mutations) {

       // Observe added img.lazy's
       for (let n of mut.addedNodes) {
           if (isElement(n) && n.tagName === 'IMG' && n.classList.contains('lazy')) {
               intOb.observe(n);
           }
       }

       // Unobserve anything removed.
       for (let n of mut.removedNodes) {
           if (isElement(n)) {
               intOb.unobserve(n);
           }
       }
    }
}

mutOb.observe(document.body, {subtree: true, childList: true});

// Now, we must observe img.lazy's already on the page
for (let x of document.body.querySelectorAll('img.lazy')) {
    intOb.observe(x);
}

Whew. If you've never seen intersection/mutation observers before, you should look them up. They require polyfills, so make sure you do that - or else your images won't load!

Remember that unlazy function? It's coming back.

Basically, we want to load an image, and when it's done loading, stick it into the dom. Sound simple? It's not.

function unlazy(lazyImage) {

    let {src, srcset, sizes} = lazyImage.dataset;

    if (src || srcset) {
        let callback = lazyImage.__callback ? lazyImage.__callback : () => undefined;

        // clone the node
        let downloadingImage = lazyImage.cloneNode();

        // make changes that would normally trigger expensive browser calculations.
        // These won't make the browser recalculate anything because it's out-of-tree.
        // The browser will inevitably have to recalculate when we swap the nodes, but
        // at least, at that point, the browser can batch together the changes.
        downloadingImage.classList.remove('lazy');
        downloadingImage.removeAttribute('data-src');
        downloadingImage.removeAttribute('data-srcset');
        downloadingImage.removeAttribute('data-sizes');
        downloadingImage.removeAttribute('src'); // the placeholder

        // now we start the download.
        if (srcset) {
            downloadingImage.srcset = srcset;
            if (sizes) {
                downloadingImage.sizes = sizes;
            }
        } else if (src) {
            downloadingImage.src = src;
        }

        // This is the official way to wait for a download
        // https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/decode
        downloadingImage.decode().then(() => {
            callback();
            lazyImage.parentNode.replaceChild(downloadingImage, lazyImage);
        });
    }
}

Basically, we're cloning the original img.lazy element, removing the lazy attributes out-of-tree (for batching), setting the clone's src/srcset, and using HTMLImageElement.decode to wait for the image to download. Once the promise is fulfilled, the clone node replaces the original node in the DOM.

Yes, there are tradeoffs to this implementation. For one, you might lose listeners and other things that depend on the reference to the image being constant. But, as far as I know, this is the only cross-browser way - once you set src or srcset, the old image is freed, and the new one starts downloading.