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:
We use next/image
here with the default loading
strategy (lazy
) and placeholder images.
Basically something like the following code:
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:
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
andpriority
(which implieseager
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:
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:
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.