Tips for rolling your own lazy loading

Avatar of Phil Hawksworth
Phil Hawksworth on (Updated on )

You may have heard (or even issued the call) that “we can just use lazy loading!” when looking for a way to slim down a particularly heavy web page.

Lazy loading is a popular technique for gradually requesting images as they come into view, rather than all at once after the HTML of the page has been parsed. It can reduce the initial page weight, and help us hit our performance budgets by requesting images when they’re needed.

It can be effective. But it also comes with some baggage of its own. We’ll get to that! In fact, Rahul Nanwani did an extensive write-up that hits several lazy-loading methods and illustrates just how complex some are.

In this post, we’ll look at an implementation that’s already been covered in brief detail in this post by Preethi. We’re going to expand on that so you can add your own implementation of lazy loading to your site as I’ve done on this little demo site.

We’ll cover these topics along the way:

Here’s what we’ll be building:

Why not native lazy loading?

Currently, lazy loading is not something browsers can do for us natively. Although that looks set to change soon for some browsers with the launch of Chrome 75 which aims to bring lazy loading support for images and iframes. Until that time (and beyond, if we are to play nicely with other browsers – which we should) lazy loading is implemented using JavaScript. There are a number of libraries and frameworks out there to help.

Some static site generators, libraries, and frameworks include utilities to provide this capability “out of the box”, which proves popular as people look for built-in ways to include this feature in their sites. But I’ve also noticed a trend where some chose to adopt entire libraries or frameworks in order to gain access to just this feature. As a thrifty, performance and inclusivity blow-hard, I’m a bit cautious about this. So let’s look at how you could implement this yourself without the need for a specific framework or library.

lazy loading example

The typical mechanics for lazy loading

Most approaches follow a pattern like this:

First, some HTML to define our lazily loaded images

<!-- 
Don't include a src attribute in images you wish to load lazily.
Instead specify their src safely in a data attribute
-->
<img data-src="lighthouse.jpg" alt="A snazzy lighthouse" class="lazy" />

When should image loading take place?

Next, we use some sort of JavaScript magic to correctly set the src attribute when the image comes into view. This was once an expensive JavaScript operation, involving listening for window scroll and resize events, but IntersectionObserver has come to the rescue.

Creating an intersection observer looks like this:

// Set up an intersection observer with some options
var observer = new IntersectionObserver(lazyLoad, {

  // where in relation to the edge of the viewport, we are observing
  rootMargin: "100px",

  // how much of the element needs to have intersected 
  // in order to fire our loading function
  threshold: 1.0

});

We’ve told our new observer to call a function named lazyLoad when those observable conditions are met. The elements that satisfy those conditions will be passed to that function so that we can manipulate them… like actually load and display them.

function lazyLoad(elements) {
  elements.forEach(image => {
    if (image.intersectionRatio > 0) {

      // set the src attribute to trigger a load
      image.src = image.dataset.src;

      // stop observing this element. Our work here is done!
      observer.unobserve(item.target);
    };
  });
};

Great. Our images will have the correct src assigned to them as they come into view, which will cause them to load. But which images? We need to tell the Intersection Observer API which elements we care about. Luckily, we assigned each one with a CSS class of .lazy for just this purpose.

// Tell our observer to observe all img elements with a "lazy" class
var lazyImages = document.querySelectorAll('img.lazy');
lazyImages.forEach(img => {
  observer.observe(img);
});

Nice. But perfect?

This seems to be working nicely, but there are some drawbacks to consider:

  1. Until (or unless) JavaScript comes along and successfully runs, we have a bunch of image elements on our page that will not work. We deliberately nixed them by removing the src attribute. That’s the result we wanted, but now we are dependent on JavaScript for these images to load. While it’s true that JavaScript is pretty well ubiquitous on the web theses days — with the web reaching such a broad spectrum of devices and network conditions — JavaScript can become an expensive addition to our performance budgets, particularly if it is involved in the delivery and rendering of content. As Jake Archibald once pointed out, all your users are non-JS while they’re downloading your JS. In other words, this is not to be taken lightly.
  2. Even when this works successfully, we have empty elements on our page which might give a bit of a visual jolt when they load in. Perhaps we can hint at the image first and do something fancy. We’ll get to that shortly.

The planned native lazy loading implementation by Chrome should help to address our first point here. If the element has been given a loading attribute, Chrome can honor the src attribute specified at the right time, rather than requesting it eagerly the moment it sees it in the HTML.

The editor’s draft of the specification includes support for different loading behaviors:

  • <img loading="lazy" />: Tell the browser to load this image lazily when needed.
  • <img loading="eager" />: Tell the browser to load this image immediately.
  • <img loading="auto" />: Let the browser make its own assessment.

Browsers without this support would be able to load the image as normal thanks to the resilient nature of HTML and browsers ignoring HTML attributes that they don’t understand.

But… sound the loud caution klaxon! This feature has yet to land in Chrome, and there is also uncertainty about if and when other browsers might choose to implement it. We can use feature detection to decide which method we use, but this still doesn’t give a solid progressive enhancement approach where the images have no dependency on JavaScript.

<img data-src="lighthouse.jpg" alt="A snazzy lighthouse" loading="lazy" class="lazy" />
// If the browser supports lazy loading, we can safely assign the src
// attributes without instantly triggering an eager image load.
if ("loading" in HTMLImageElement.prototype) {
  const lazyImages = document.querySelectorAll("img.lazy");
  lazyImages.forEach(img => {
    img.src = img.dataset.src;
  });
}
else {
  // Use our own lazyLoading with Intersection Observers and all that jazz
}

As a companion to responsive images

Assuming that we are comfortable with the fact that JavaScript is a dependency for the time being, let’s turn our attention to a related topic: responsive images.

If we’re going through the trouble of delivering images into the browser only when needed, it seems fair that we might also want to make sure that we are also delivering them in the best size for how they’ll be displayed. For example, there’s no need to download the 1200px-wide version of an image if the device displaying it will only give it a width of 400px. Let’s optimize!

HTML gives us a couple of ways to implement responsive images which associate different image sources to different viewport conditions. I like to use the picture element like this:

<picture>
  <source srcset="massive-lighthouse.jpg" media="(min-width: 1200px)">
  <source srcset="medium-lighthouse.jpg" media="(min-width: 700px)">
  <source srcset="small-lighthouse.jpg" media="(min-width: 300px)">
  <img src="regular-lighthouse.jpg" alt="snazzy lighthouse" />
</picture>

You’ll notice that each source element has a srcset attribute which specifies an image URL, and a media attribute that defines the conditions under which this source should be used. The browser selects the most suitable source from the list according to the media conditions with a standard img element acting as a default/fallback.

Can we combine these two approaches to make lazy-loading responsive images?

Of course, we can! Let’s do it.

Instead of having an empty image until we do our lazy load, I like to load a placeholder image that has a tiny file size. This does incur the overhead of making more HTTP requests, but it also gives a nice effect of hinting at the image before it arrives. You might have seen this effect on Medium or as a result of a site using Gatsby’s lazy loading mechanics.

We can achieve that by initially defining the image sources in our picture element as tiny versions of the same asset and then using CSS to scale them to the same size as their higher-resolution brothers and sisters. Then, through our intersection observer, we can update each of the specified sources to point at the correct image sources.

So, initially our picture element might look like this:

<picture class="lazy">
  <source srcset="tiny-lighthouse.jpg" media="(min-width: 1200px)">
  <source srcset="tiny-lighthouse.jpg" media="(min-width: 700px)">
  <source srcset="tiny-lighthouse.jpg" media="(min-width: 300px)">
  <img src="tiny-lighthouse.jpg" alt="snazzy cake" />
</picture>

No matter what viewport size is applied, we’ll display a tiny 20px image. We’re going to blow it up with CSS next.

Previewing the image with style

The browser can scale up the tiny preview image for us with CSS so that it fits the entire picture element rather than a mere 20px of it. Things are going to get a little… pixelated, as you may imagine when a low-resolution image is blown up to larger dimensions.

picture {
  width: 100%; /* stretch to fit its containing element */
  overflow: hidden;
}

picture img {
  width: 100%; /* stretch to fill the picture element */
}
blurred image

For good measure, we can soften that pixelation introduced by scaling up the image by using a blur filter.

picture.lazy img {
  filter: blur(20px);
}
more fully blurred image

Switching sources with JavaScript

With a little adaptation, we can use the same technique as before to set the correct URLs for our srcset and src attributes.

function lazyLoad(elements) {

  elements.forEach(picture => {
    if (picture.intersectionRatio > 0) {

      // gather all the image and source elements in this picture
      var sources = picture.children;

      for (var s = 0; s < sources.length; s++) {
        var source = sources[s];

        // set a new srcset on the source elements 
        if (sources.hasAttribute("srcset")) {
          source.setAttribute("srcset", ONE_OF_OUR_BIGGER_IMAGES);
        }
        // or a new src on the img element
        else {
          source.setAttribute("src", ONE_OF_OUR_BIGGER_IMAGES);
        }
      }

      // stop observing this element. Our work here is done!
      observer.unobserve(item.target);
    };
  });

};

One last step to complete the effect: remove that blur effect from the image once the new source has loaded. A JavaScript event listener waiting for the load event on each new image resource can do that for us.

// remove the lazy class when the full image is loaded to unblur
source.addEventListener('load', image => {
  image.target.closest("picture").classList.remove("lazy")
}, false);

We can make a nice transition that eases away the blur away, with a sprinkle of CSS.

picture img {
  ...
  transition: filter 0.5s,
}

A little helper from our friends

Great. With just a little JavaScript, a few lines of CSS and a very manageable dollop of HTML, we’ve created a lazy loading technique which also caters for responsive images. So, why aren’t we happy?

Well, we’ve created two bits of friction:

  1. Our markup for adding images is more complex than before. Life used to be simple when all we needed was a single img tag with a good old src attribute.
  2. We’ll also need to create multiple versions of each image assets to populate each viewport size and the pre-loaded state. That’s more work.

Never fear. We can streamline both of these things.

Generating the HTML elements

Let’s look first at generating that HTML rather than authoring it by hand each time.

Whatever tool you use to generate your HTML, chances are that it includes a facility to use includes, functions, shortcodes, or macros. I’m a big fan of using helpers like this. They keep more complex or nuanced code fragments consistent and save time from having to write lengthy code. Most static site generators have this sort of ability.

  • Jekyll lets you create custom Plugins
  • Hugo gives you custom shortcodes
  • Eleventy has shortcodes for all of the template engines it supports
  • There are many more…

As an example, I made a shortcode called lazypicture in my example project built with Eleventy. The shortcode gets used like this:

{% lazypicture lighthouse.jpg "A snazzy lighthouse" %}

To generate the HTML that we need at build time:

<picture class="lazy">
  <source srcset="/images/tiny/lighthouse.jpg" media="(min-width: 1200px)">
  <source srcset="/images/tiny/lighthouse.jpg" media="(min-width: 700px)">
  <source srcset="/images/tiny/lighthouse.jpg" media="(min-width: 300px)">
  <img src="/images/tiny/lighthouse.jpg" alt="A snazzy lighthouse" />
</picture>

Generating the image assets

The other bit of work we have created for ourselves is generating differently sized image assets. We don’t want to manually create and optimize each and every size of every image. This task is crying out for some automation.

The way you choose to automate this should take into account the number of images assets you need and how regularly you might add more images to that set. You might chose to generate those images as part of each build. Or you could make use of some image transformation services at request time. Let’s look a little at both options.

Option 1: Generating images during your build

Popular utilities exist for this. Whether you run your builds with Grunt, Gulp, webpack, Make, or something else, chances are there is utility for you.

The example below is using gulp-image-resize in a Gulp task as part of a Gulp build process. It can chomp through a directory full of image assets and generate the variants you need. It has a bunch of options for you to control, and you can combine with other Gulp utilities to do things like name the different variants according to the conventions you choose.

var gulp = require('gulp');
var imageResize = require('gulp-image-resize');

gulp.task('default', function () {
  gulp.src('src/**/*.{jpg,png}')
    .pipe(imageResize({
      width: 100,
      height: 100
    }))
    .pipe(gulp.dest('dist'));
});

The CSS-Tricks site uses a similar approach (thanks to the custom sizes feature in WordPress) to auto-generate all of its different image sizes. (Oh yeah! CSS-Tricks walks the walk!) ResponsiveBreakpoints.com provides a web UI to experiment with different settings and options for creating images sets and even generates the code for you.

Or, you can use it programmatically as Chris mentioned on Twitter.

When you have as many image files as CSS-Tricks, though, doing this work as part of a build step can become cumbersome. Good caching in your build and other file management tasks can help, but it can be easy to end up with a lengthy build process that heats up your computer as it performs all of the work.

An alternative is to transform these resources at request time rather than during a build step. That’s the second option.

Option 2: On-demand image transformations

I’m a loud advocate of pre-rendering content. I’ve shouted about this approach (often referred to as JAMstack) for quite some time, and I believe that it has numerous performance, security and simplicity benefits. (Chris summed this up nicely in a post about static hosting and JAMstack.)

That said, the idea of generating different image sizes at request time might seem to be contrary to my lazy loading objectives. In fact, there are a number of services and companies now, who specialize in this and they do it in a very powerful and convenient way.

Combining image transformations with powerful CDN and asset caching capabilities by companies like Netlify, Fastly, and Cloudinary can rapidly generate images with the dimensions you pass to them via a URL. Each service has significant processing power to perform these transformations on the fly, then cache the generated images for future use. This makes for seamless rendering for subsequent requests.

Since I work at Netlify, I’ll illustrate this with an example using Netlify’s service. But the others I mentioned work in similar ways.

Netlify’s Image Transformation service builds on top of something called Netlify Large Media. This is a feature created to help manage large assets in your version control. Git is not very good at this by default, but Git Large File Storage can extend Git to make it possible to include large assets in your repos without clogging them up and making them unmanageable.

You can read more on the background of that approach for managing large assets if you are interested.

Placing images under version control in our Git repositories is an added bonus, but for our purposes, we are more interested in enjoying the benefits of making on-the-fly transformations of those images.

Netlify looks for querystring parameters when transforming images. You can specify the height, width and the type of crop you’d like to perform. Like this:

  • A raw image with no transformations:
    /images/apple3.jpg
  • An image resized to be 300px wide:
    /images/apple3.jpg?nf_resize=fit&w=300
  • An image cropped to be 500px by 500px with automated focal point detection:
    /images/apple3.jpg?nf_resize= smartcrop&w=500&h=500

Knowing that we can create and deliver any image sizes from a single source image in our version control means that the JavaScript we use to update the image sources only need to include the size parameters we choose.

The approach can drastically speed up your site build processes because the work is now outsourced and not performed at build time.

Wrapping it all up

We’ve covered a lot of ground here. There are a lot of very achievable options for implementing responsive images with lazy loading. Hopefully, this will give enough info to make you think twice about reaching for the nearest available framework to gain access to this sort of functionality.

This demo site pulls together a number of these concepts and uses Netlify’s image transformation service.

One last time, to summarize the flow

  • A static site generator with a shortcode eases the task of creating the picture elements
  • Netlify Large Media hosts and transforms the images, then serves them as tiny 20px-wide versions before the larger files are loaded as needed.
  • CSS scales up the tiny images and blurs them to create the preview placeholder images.
  • The Intersection Observer API detects when to swap the image assets for the appropriate larger versions.
  • JavaScript detects the load event for the larger images and removes out the blur effect to reveal the higher-resolution rendering.