Building a high performance lazy load module

For those who are not familiar, lazy loading means to only load in the content that is inside the users viewport, when the user scrolls down more content is loaded in. This has a number of benefits:

First I will create a very rudamentary, low performance lazy load with the use of some data attributes and some JavaScript. Then I will demonstrate how we can vastly improve the performance.

With the images we populate the src with a blank GIF, it inavlidates the markup if you leave the src attribute blank. Then you would put the url of the image into the lazy-src data attribute. Put a JavaScript hook into the classname, and don’t forget to put some descriptive text on the alt attribute.

<div>
   <img src="blank.gif"
      data-lazy-src="img/photo-1"
      class="js-lazy-load"
      alt="Stood overlooking the city of New York">
</div>
<div>
   <img src="blank.gif"
   data-lazy-src="img/photo-2"
   class="js-lazy-load"
   alt="A view over the San Francisco Bay">
</div>

 

In our JavaScript we should create an IIFE to ensure that none of our variables leak out into the global scope.

N.B. Never omit the var keyword or else that variable will instead be bound to the global scope. This is bad.

(function(){
   // lazy load module goes here    
}());

 

Next we need to bind a scroll event handler to the window object, declare an anomynous function to be called on the scroll event. Then store the current window Y position &nbash; in other words how far we have scrolled from the top of the document. We also store the height of the window, and use that value to determine the position of the bottom of the window. This will be used to see if the images are inside the viewport.

(function(){
   window.onscroll = function() {
      var scrollOffset = $(window).scrollTop(),
         winHeight = $(window).height(),
         windowBottomOffset = scrollOffset + winHeight;
   };
}());

 

Loop through all the lazy images by targeting the .js-lazy-load hook, inside of the loop we store the images’ offset and compare that with the window bottom offset to determine if the image is within the viewport. We also store the lazy-src attribute value.

(function(){
   window.onscroll = function() {
      var scrollOffset = $(window).scrollTop(),
         winHeight = $(window).height(),
         windowBottomOffset = scrollOffset + winHeight;
      $('.js-lazy-load').each(function(){
         var imgOffset = $(this).offset().top,
            lazySrc = $(this).data('lazy-src');
         if (imgOffset < windowBottomOffset) {
            
         }
      });
   };
}());

 

Finally inside the if statement we set the url of the src attribute to the lazySrc value.

(function(){
   window.onscroll = function() {
      var scrollOffset = $(window).scrollTop(),
         winHeight = $(window).height(),
         windowBottomOffset = scrollOffset + winHeight;
      $('.js-lazy-load').each(function(){
         var imgOffset = $(this).offset().top,
            lazySrc = $(this).data('lazy-src');
         if (imgOffset < windowBottomOffset) {
            $(this).attr('src', lazySrc);       
         }
      });
   };
}());

 

So that is a basic lazy load function. It works but there’s huge room for improvement in terms of performance, the poor performance of this function would be clearly noticeable if there’s many lazy-loaded images or other effects bound to scroll events such as parallax. This would be even more profound on lower powered devices.

##Micro optimisations## There are a few things we can do to the code to slighty improve the speed of execution..

Declare scrollOffset, winHeight and windowBottom offset outside of the scroll event.

(function(){
   var scrollOffset = null,
      winHeight = $(window).height(),
      windowBottomOffset = null;
   window.onscroll = function() {
      scrollOffset = $(window).scrollTop(),
      windowBottomOffset = scrollOffset + winHeight;
      $('.js-lazy-load').each(function(){
         var imgOffset = $(this).offset().top,
            lazySrc = $(this).data('lazy-src');
         if (imgOffset < windowBottomOffset) {
            $(this).attr('src', lazySrc);       
         }
      });
   };
}());

 

inside the jQuery each loop we access $(this) three times, we should store $(this) inside of a variable for faster access.

(function(){
   var scrollOffset = null,
      winHeight = $(window).height(),
      windowBottomOffset = null;
   window.onscroll = function() {
      scrollOffset = $(window).scrollTop(),
      windowBottomOffset = scrollOffset + winHeight;
      $('.js-lazy-load').each(function(){
         var el = $(this);
            imgOffset = el.offset().top,
            lazySrc = el.data('lazy-src');
         if (imgOffset < windowBottomOffset) {
            el.attr('src', lazySrc);       
         }
      });
   };
}());

 

These mico optimisations will help speed up the execution but compared to other optimisations we can make, their influence will be minimal, still it’s good to follow best practices and code sensibly in this manner.

##DOM interactions are the enemy of performance## Any query or manipulation of the DOM is very expensive, if we are building with performance in mind we need to ensure we need to reduce the amount of DOM interraction. First off the scroll event fires incredibly rapidly, this can be clearly seen if you console log out a value on the scroll event.

(function(){
   var i=0;
   window.onscroll = function() {
      console.log(i++);
   };
}());

 

There are a number of ways to reduce this, one of them is known as throttling where we create a debounce function to reduce the number of times the callback function in the scroll event is called.

But my preferred method for this is just simply using a setInterval to fire every 200ms, then we could compare the current scroll position with the previous to determine if the user has scrolled.

The code below implements this, and you will see the scroll position is only logged to the console if the user has scrolled, while being simultaneously throttled. The reason I choose the setInterval method is because most mobile browsers, especially those on iOS will ignore scroll events during the scroll animation. This circumvents that issue.

(function(){
   var scrollOffset = $(window).scrollTop(),
      intervalDuration = 200;
   setInterval(hasScrolled, intervalDuration);
   function hasScrolled() {
      var newScrollOffset = $(window).scrollTop();
      if (newScrollOffset !== scrollOffset) {
         scrollOffset = newScrollOffset;
         console.log(scrollOffset);
      }
   };
}());

 

Now we can combine this with the lazy load code function we wrote earlier.

(function(){
   var scrollOffset = $(window).scrollTop(),
      intervalDuration = 200,
      winHeight = $(window).height(),
      windowBottomOffset = null;
   setInterval(hasScrolled, intervalDuration);
   function hasScrolled() {
      var newScrollOffset = $(window).scrollTop();
      if (newScrollOffset !== scrollOffset) {
         scrollOffset = newScrollOffset;
         lazyLoad();
      }
   };
   function lazyLoad() {
      windowBottomOffset = scrollOffset + winHeight;
      $('.js-lazy-load').each(function(){
         var el = $(this);
            imgOffset = el.offset().top,
            lazySrc = el.data('lazy-src');
         if (imgOffset < windowBottomOffset) {
            el.attr('src', lazySrc);       
         }
      });
   }
}());

So now the scroll is throttled , this in iself greatly improves the performance but we are still making DOM querys. Every time the lazyLoad() function is called we have to calculate the images offset. It would be more efficient to determine the offset once and store that value.

##Determining the position of the images## Initially this sounds very simple, on page-load we can just grab the offsetTop of the images and store the values in the array, but upon further investigation this presents a problem. When we scroll to an image and it loads in, it pushes the content below further down the page, rendering the remaning offset values invalid.

With JavaScript we cannot determine an images size until it has been loaded. However we could use the server side to populate a set of data-attributes with the dimensions and use those to create a placeholder, meaning that once the image loads in, it won’t take up any more space on the document.

Responsive design makes this a little more complex because the images can scale to their container with max-width: 100%; and height:auto; set.

It is possible to make a HTML block element maintain it’s aspect ratio when scaled using the padding-bottom percentage hack. This is how we will build our placeholder.

<div class="lazy-load-wrap">
   <div class="lazy-load-wrap__inner">
      <img src="blank.gif"
         class="js-lazy-load"
         data-lazy-src="img/photo-1"
         width="400"
         height="300"
         alt="Stood overlooking the city of New York">
   </div>
</div>

<div class="lazy-load-wrap">
   <div class="lazy-load-wrap__inner">
      <img src="blank.gif"
         class="js-lazy-load"
         data-lazy-src="img/photo-2"
         width="400"
         height="300"
         alt="A view over the San Francisco Bay">
   </div>
</div>

 

Lets create the necessary CSS for this to work. We force the image to be contrained to the wrapping element’s proportions. The padding-bottom and width to be added to .lazy-load-wrap will be calculated in the JavaScript and applied there via jQuery’s `.css method.

.lazy-load-wrap__inner {
    position: relative;
    background: #eee;
    
}

.lazy-load-wrap img {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
}

 

First loop through all the lazy-load images, get the width and height attributes and use this to calculate the aspect ratio as a percentage which then gets applied to the parent element in the padding-bottom property, then that element’s parent gets it’s max-width attribute augmented with the images width.
This forces the block to behave like a responsive image of those dimensions.

So we now have our placeholders.

function generatePlaceholders() {
   $('.js-lazy-load').each(function() {
      var el = $(this),
         width = el.data('width'),
         height = el.data('height'),
         paddingBottom = (height/width) * 100;
      el.parent().css('padding-bottom', paddingBottom + '%')
         .parent().css('max-width', width + 'px');
   });
};

 

The full code should now look like this:

(function(){
   var scrollOffset = $(window).scrollTop(),
      intervalDuration = 200,
      winHeight = $(window).height(),
      windowBottomOffset = null;
      setInterval(hasScrolled, intervalDuration);
      
   //add new generate placeholders function
   function generatePlaceholders() {
      $('.js-lazy-load').each(function() {
         var el = $(this),
            width = el.data('width'),
            height = el.data('height'),
            paddingBottom = (height/width) * 100;
         el.parent().css('padding-bottom', paddingBottom + '%')
            .parent().css('max-width', width + 'px');
      });
   };
   generatePlaceholders();
   
   
   function lazyLoad() {
      windowBottomOffset = scrollOffset + winHeight;
      $('.js-lazy-load').each(function(){
         var el = $(this);
            imgOffset = el.offset().top,
            lazySrc = el.data('lazy-src');
         if (imgOffset < windowBottomOffset) {
            el.attr('src', lazySrc);       
         }
      });
   }

   function hasScrolled() {
      var newScrollOffset = $(window).scrollTop();
      if (newScrollOffset !== scrollOffset) {
         scrollOffset = newScrollOffset;
         lazyLoad();
      }
   };
}());

 

##Precalculating the offsets## One benefit I forgot to mention of the placeholder is that once the image loads the page will not have to be repainted unlike before because the placeholder already takes up that space so another +1 for the performance, but we’re not there yet.

We’re still calculating the image offset with every scroll. Lets determine those in a seperate function and store that data in an array that can be accessed when we scroll.

It’s Much faster to loop through an array of values than it is to pull them from the DOM.

First we have to set an empty array that can be later accessed

(function(){
   var scrollOffset = $(window).scrollTop(),
      intervalDuration = 200,
      winHeight = $(window).height(),
      windowBottomOffset = null,
      // add empty image array.
      images = [];
      
      ...
      ...

}());

 

Next we should augment the generatePlaceholders() function, while inside the each loop we should also get the image imgOffset and lazySrc` values, then we can push the element reference, imgOffset and lazySrc as a single object to the images array which can be accessed later then the user scrolls.

Maybe we should now rename this function to initialize seen as generatePlaceholders is no longer descriptitve of it’s function.

function initialize() {
   $('.js-lazy-load').each(function() {
      var el = $(this),
         width = el.data('width'),
         height = el.data('height'),
         paddingBottom = (height/width) * 100,
         imgOffset = el.offset().top,
         lazySrc = el.data('lazy-src');
      images.push({
         el: el,
         imgOffset: imgOffset,
         lazySrc: lazySrc
      });
      el.parent().css('padding-bottom', paddingBottom + '%')
         .parent().css('max-width', width + 'px');
   });
}
initialize();

 

Now we can re-write the lazyLoad() function, instead of querying the DOM for the images we loop through the images array and test the stored offset value against the window scroll positon, if the element is within the viewport it loads the image.

function lazyLoad() {
   var item = null;
   windowBottomOffset = scrollOffset + winHeight;
   for (var i=0,l=images.length; i < l; i++) {
      item = images[i];
      if (item.imgOffset < windowBottomOffset) {
         item.el.attr('src', item.lazySrc);  
      }
   }
}

 

This is now far more optimised than it was previously, but there’s a little more we can do. After an image has been loaded there’s no need to keep looping over it’s data in the array, so we should remove it from the array.

We can do that with the Array.splice() method.

function lazyLoad() {
   var item = null;
   windowBottomOffset = scrollOffset + winHeight;
   for (var i=0,l=images.length; i < l; i++) {
      item = images[i];
      if (item.imgOffset < windowBottomOffset) {
         item.el.attr('src', item.lazySrc);
         // remove from array
         images.splice(i, 1);
         /* recursively call the lazyLoad() fn until all images 
         for current scroll are loaded. */
         lazyLoad();  
      }
   }
}

##Putting it all together##

This is now pretty much about as optimised as it can get. We could offer different image sizes for smaller screens, but that is beyond the scope of this post. I’m currenlty running this on projects that include parallax effect and everything now typically runs at 60FPS or better. My old lazy-loading script was the bottleneck stopping me from acheiving that kind of frame rate.

Here is all the completed code:

<div class="lazy-load-wrap">
   <div class="lazy-load-wrap__inner">
      <img src="blank.gif"
         class="js-lazy-load"
         data-lazy-src="img/photo-1"
         width="400"
         height="300"
         alt="Stood overlooking the city of New York">
   </div>
</div>

<div class="lazy-load-wrap">
   <div class="lazy-load-wrap__inner">
      <img src="blank.gif"
         class="js-lazy-load"
         data-lazy-src="img/photo-2"
         width="400"
         height="300"
         alt="A view over the San Francisco Bay">
   </div>
</div>

 

.lazy-load-wrap__inner {
    position: relative;
    background: #eee;
    
}

.lazy-load-wrap img {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
}

 

(function(){
   var scrollOffset = $(window).scrollTop(),
      intervalDuration = 200,
      winHeight = $(window).height(),
      windowBottomOffset = null,
      images = [];
   
   setInterval(hasScrolled, intervalDuration);
   
   function initialize() {
      $('.js-lazy-load').each(function() {
         // loop through images and populate array with data
         var el = $(this),
            width = el.data('width'),
            height = el.data('height'),
            paddingBottom = (height/width) * 100,
            imgOffset = el.offset().top,
            lazySrc = el.data('lazy-src');
         images.push({
            el: el,
            imgOffset: imgOffset,
            lazySrc: lazySrc
         });
         // set placeholder dimensions
         el.parent().css('padding-bottom', paddingBottom + '%')
         .parent().css('max-width', width + 'px');
      });
   }
   initialize();


   function hasScrolled() {
      var newScrollOffset = $(window).scrollTop();
      if (newScrollOffset !== scrollOffset) {
         // user has scrolled
         scrollOffset = newScrollOffset;
         lazyLoad();
      }
   }


   function lazyLoad() {
      var item = null;
      windowBottomOffset = scrollOffset + winHeight;
      for (var i=0,l=images.length; i<l; i++) {
         item = images[i];
         if (item.imgOffset < windowBottomOffset) {
            item.el.attr('src', item.lazySrc);
            images.splice(i, 1);
            lazyLoad();
         }
      }
   }
}());

N.B. The module should, of course be placed within a DOM ready event.

##Demo##

 

 

If anybody has anything to add, pehaps some way to optimise things even further then please leave a comment below. Thanks for reading.