Next.js includes many image optimizations for faster web apps out of the box - especially lazy images that defer loading until an image is visible. But this means images will not be instantly visible when scrolling. So while Next.js offers a super fast load of the page (which is good for web vitals), the user will see images loading while scrolling. While working on our latest Next.js project we developed a trick to have it both: the best user experience and the best load times.

Let's see an example with lazy images and placeholders:

Lazy images reduce initial load time, but images load only when becoming visible

We use next/image here with the default loading strategy (lazy) and placeholder images. Basically something like the following code:

pages/index.js
import Image from "next/image";

import myImage from "../public/images/my-image.jpg";

const Home = () => (
  <>
    ...
    <Image
      src={myImage}
      alt="I'm a lazy image"
      width="600"
      height="400"
      placeholder="blur"
    />
    ...
  </>
);

We actually use plaiceholder and generate blur data URLs for images via getStaticProps on our site. We also use a custom image loader with imgproxy. But it doesn't matter for this example - the issue with lazy loading is the same.

While offering a great experience on mobile clients with restricted bandwidth or limited data volume, this is not ideal for fast connections or desktop users.

So can we do better?

Our goal was to keep the optimized load time for clients that are on a mobile connection and activate eager loading for everyone else after the site was loaded.

It turns out the NetworkInformation API can be used to detect the connection type and effective bandwith - but browser adoption stalled a bit since 2015. Luckily mobile browsers mostly support the API (Safari on iOS does not...). We decided to not use a polyfill for now, since it involves extra requests and added complexity, so iOS users will get the high fidelity behavior.

Combined with some event listeners and the requestIdleCallback API, we can now wrap the next/image component:

components/Image.js
import NextImage from 'next/image';
import { useEffect, useState } from 'react';

const Image = (props) => {
  const [loading, setLoading] = useState(props.loading);

  useEffect(() => {
    // Skip if image is already eager or has priority (disables lazy loading)
    if (props.loading === 'eager' || props.priority) {
      return;
    }

    if (!isMobileConnection()) {
      let clearDefer;
      // Set loading to eager if all resources of document are loaded, but defer it a bit
      const onLoad = () => {
        clearDefer = defer(() => setLoading('eager'));
      };
      window.addEventListener('load', onLoad);
      return () => {
        // Clean up the load event listener and an eventual defer
        window.removeEventListener('load', onLoad);
        if (clearDefer) {
          clearDefer();
        }
      };
    }
  }, [props.loading, props.priority]);

  return <NextImage loading={loading} {...props} />;
};

const isMobileConnection = () => {
  const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
  return (
    connection?.type === 'cellular' ||
    connection?.effectiveType === 'slow-2g' ||
    connection?.effectiveType === '2g' ||
    connection?.effectiveType === '3g' ||
    connection?.saveData === true
  );
};

const defer = (callback) => {
  // Check if we can use requestIdleCallback
  if (window.requestIdleCallback) {
    const handle = window.requestIdleCallback(callback);
    return () => window.cancelIdleCallback(handle);
  }
  // Just defer using setTimeout with some random delay otherwise
  const handle = setTimeout(callback, 2345 + Math.random() * 1000);
  return () => clearTimeout(handle);
};

export default Image;

In a nutshell the new component does the following:

  • Set up a state for loading which defaults to the passed loading prop
  • Add an effect that depends on the props loading and priority (which implies eager if set)
  • The effect will check if we have a mobile connection using the NetworkInformation API
  • If we do not have a mobile connection, it will add an event listener for the load event
  • In onLoad the actual change of the state is deferred, so that the image is not changed immediately, but when the browser is idle
  • The effect finally cleans up the event listener and the defer handle

This Image component can be used as a drop-in replacement for the next/image component just by changing the import:

pages/index.js
import Image from "../components/Image.js";

import myImage from "../public/images/my-image.jpg";

const Home = () => (
  <>
    ...
    <Image
      src={myImage}
      alt="I'm a lazy image"
      width="600"
      height="400"
      placeholder="blur"
    />
    ...
  </>
);

Now let's see how the loading experience looks when using the custom Image component:

Images are loaded lazily initially and switched to eager loading after the page did loaded and no mobile connection could be detected - all images are already present when scrolling

This looks much better when scrolling through the page. It shouldn't decrease the initial load time, since images are set to load eager only after the load event was fired.

We have found one caveat though: When running Lighthouse tests, the additional image loads prevented a successful run in Lighthouse CI. We tried different approaches to solve this, but ended up in testing for Chrome-Lighthouse in the user agent and disabled switching to eager loading.

Feel free to use the code above in your own projects. And of course I'm keen to hear feedback or alternative solutions. Just drop me a line at @hlubek.