Ashley Sheridan​.co.uk

Using Intersection Observer to Improve Image Loading Performance

Posted on

Tags:

Page performance is always something we should consider when building websites and applications, whether that's real or the way that a user perceves your site is loading. Some experts would actually say that in some situations, perception of load times is more important than actual load times; it's why some people prefer progress bars over spinners.

Images and Performance

One of the causes of poor page performance is large amounts of images loading on a page when they're not even visible. If a user doesn't scroll through the entire pages content, then those loaded images are wasted. That's bad for a few reasons:

  • Some of your users will be on metered data connections, which means that they are more concerned over their data usage.
  • Your page weight is built up with content that isn't used, and more weight means slower load times
  • The more requests your browser makes for external resources, the longer it takes to complete. Have you ever tried to transfer a thousand images? It takes longer than a single video, even if the video is only a little larger than the combined size of all of those images.

The Solution

There is a simple fix to the problem; just load the images that you need. The fix is actually simple, and needs only about 30 lines of JavaScript. Mainly that's thanks to an API called Intersection Observer that has pretty good browser support (except Internet Explorer, but usage of that is ever dropping.) This allows you to trigger events when items scroll into view, which makes it perfect for use with an image preloader in a photo gallery.

Stopping Images from Loading by Default

The first step is to stop images loading by default, so the images in the gallery look like this in the HTML:

<img src="" data-src="real-image.jpg" class="preload" alt="example image"/>

You can improve the appearance by using a much smaller sample image into the src attribute, and ideally you would have this inline as a base64 encoded image:

<img class="preload" src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhCY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wgARCAAPABcDAREAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAMF/8QAFwEAAwEAAAAAAAAAAAAAAAAAAgMEBf/aAAwDAQACEAMQAAAAjn1pYodInjW2o88lf//EACQQAAICAAUDBQAAAAAAAAAAAAEDAgQABRESISJBQhMjMlHh/9oACAEBAAE/ALWXJKt0bEoPJ5l2xlynLqmVdsZxVEiO7zP3imwslJlzicDoYM78foOEhV5bYwj0DqkdxB10xltV1/2K0wlMBtZi45qsw9Kz8lAgSj5DUjH/xAAdEQACAgMAAwAAAAAAAAAAAAABAgADERIhBEGh/9oACAECAQE/AK/IJtyw5GuVG09n5LWU4xNigAjVB3DgdlqleT//xAAdEQEAAgIDAQEAAAAAAAAAAAABAAIDBBEhMRMi/9oACAEDAQE/AK6P55PY6ycr5Lv1CodEc11OWOzamNozCj3P/9k=" alt="" data-src="beetles-450-300.jpg"/>

There is a caveat with this last part: the encoded version of the image is always larger in size than the original image, and when the image is eventually loaded, the page weight may be much larger. Shrink that image down as far as you can (remember,it's just to give a hint of the final image) and run it through an optimiser.

Add Intersection Observers

The next stage is to add in the observers that can fire off events when an image scrolls into view:

function observerHandler(observerEntries, observer) { for(let observerEntry of observerEntries) { if(observerEntry.isIntersecting && !image.hasPreloader) { console.log("in view"); image.src = image.getAttribute("data-src"); } } } let images = document.querySelectorAll("img.preload"); for(let image of images) { image.observer = new IntersectionObserver(observerHandler); image.observer.observe(image); }

Load the Images Seamlessly

If we just swapped out the src attributes value with the real one as soon as the image was scrolled into view, we would lose out on the base64 encoded image we had already used while we waited for it to load. On a photo gallery full of images, that experience would be quite jarring. Instead, we want to load that real image behind the scenes, and then use it in the DOM once it was ready.

To do this, we need to change a couple of bits. First, we add in an image object to our image element:

for(let image of images) { image.observer = new IntersectionObserver(observerHandler); image.observer.observe(image); image.preloadObj = new Image(); image.preloadObj.addEventListener("load", imagePreloader); image.preloadObj.parent = image; }

We're going to use that Image() object in JavaScript to load our real data, and then once it's fired its load event, we'll run the imagePreloader() event handler. You might have noticed in this example that I'm creating a reference to the image element in our Javascript image object. This is to make the code a little cleaner and let us reference that element more easily in the imagePreloader() handler:

function imagePreloader(event) { let preloadImageObj = event.target; let image = preloadImageObj.parent; image.src = preloadImageObj.src; }

Then we alter our observer event handler to update the new objects src property with the value of the data-src attribute, and set the hasPreloader property to true:

function observerHandler(observerEntries, observer) { for(let observerEntry of observerEntries) { let image = observerEntry.target; if(observerEntry.isIntersecting && !image.hasPreloader) { console.log("in view"); image.preloadObj.src = image.getAttribute("data-src"); } } }

Limit Requests

Currently, our implementation does work, but it's not optimal. Every time our image element is scrolled into view in the page, we perform the loading steps all over again, even if the image is already loaded (or is being loaded). There is something we can do about that by keeping track of which elements have had their preloader initiated. We need to add a new property on our image element:

for(let image of images) { // rest of the observer and image object code here image.hasPreloader = false; }

Then we reference that when we're checking to see if the observer event actually brought our image into view, and set the hasPreloader property to true to indicate that we have already initiated the preloader for this image:

if(observerEntry.isIntersecting && !image.hasPreloader) { console.log("in view"); image.hasPreloader = true; image.preloadObj.src = image.getAttribute("data-src"); }

This way, we only trigger an image to be fetched once (even if the image is cached, your browser might opt to make a new HEAD request against the image URL).

Load Images Just Before They're Needed

Currently we're loading images just as they scroll into view. Unless your users have a fast connection, you can't guarantee that the images will be there in a timely manner. It would be nice if we could load those images just _before_ we needed them, so the user doesn't have to experience the extremely low quality placeholder.

The way to achieve that is by passing in an options object:

let observerOptions = { rootMargin: "400px" }; ... image.observer = new IntersectionObserver(observerHandler, observerOptions);

The options object allows you to affect a few different things about the way the intersection observers behave, but the one that we're interested in here is the rootMargin. This acts a little like a CSS margin property (and it too accepts 4 separate values) and allows you to effectively set an extra area around the element that can act as part of what triggers the event. By setting it to 400px here the image will trigger the observer event 400 pixels before it is actually visible. You can adjust that according to the type and amount of content your website has.

Seeing it in Action

See the Pen using intersection observer to improve image loading performance by Ashley Sheridan (@AshleyJSheridan) on CodePen.

Comments

Leave a comment