Wednesday, February 1, 2012

Delayed Image Loading on Long Pages

On EatDifferent, we let users track their meals with both text and pictures, and now that we have iPhone and Android apps, more and more users are using the mobile apps to quickly snap photos of their meals during the day. That means that the stream of updates on the home page includes a lot of images — a thumbnail for each user updating or commenting, and a thumbnail of each meal (and one user even takes photos of their blood glucose meter before each meal!). Here's a highly scientific diagram of that:

I realized that users were downloading all of these images for the stream, even though many of them were offscreen, and well, I felt bad for unnecessarily using their bandwidth and for making their experience slower. So, I decided to only load the images that were visible (or, nearly visible). Here's how I did it:

The HTML

Normally, when you write an IMG into your HTML, you assign the URL to the src attribute. I didn't want to do that anymore, though, since the browser would then load that URL, so I instead assign the URL to a data-src attribute — something the browser will ignore, but that my JavaScript can pick up. Here's what my template looks like:

<div class="stream-user-img">
 <a href="{{=log.user.profileUrl}}"><img data-src="{{=log.user.photoUrl}}"></a>
</div>

The JavaScript

Now that I have src-less IMG tags, I need a function to decide which IMGs to src-ify. Here's what the function needs to verify:

  • The IMG must have an empty src and a non-empty data-src attribute.
  • The IMG must be visible per CSS - not visibility:hidden or display:none.
  • The IMG must be user-visible, within the viewport. Or, as I realized, it should be nearly visible, so that users don't see the IMGs loading most of the time. I decided on 500 pixels as a reasonable near threshold, just by playing and seeing what felt right.

Here's what that looks like using jQuery:

function loadVisibleImages() {
  $('img').each(function() {
    if (this.src === '' && $(this).attr('data-src') && $(this).is(':visible'))) {
      if (inView($(this), 300)) {
        this.src = $(this).attr('data-src');
      }
    }
   });
}

function inView(elem, nearThreshold) {
  var viewportHeight = getViewportHeight();
  var scrollTop = (document.documentElement.scrollTop ?
          document.documentElement.scrollTop :
          document.body.scrollTop);
  var elemTop    = elem.offset().top;
  var elemHeight = elem.height();
  nearThreshold = nearThreshold || 0;
  if ((scrollTop + viewportHeight + nearThreshold) > (elemTop + elemHeight)) {
    return true;
  }
  return false;
}

Okay, now that we have that function, we need to call it at the appropriate times. It should be called when:

  • New IMG tags are written into the page (after data loading and template rendering, for example).
  • IMG tags go from hidden to visible.
  • IMG tags come into view after a user scroll.

I didn't find an easy way of programmatically detecting those first two cases, so I simply call loadVisibleImages() after my template rendering and visibility toggling (which only happens in two places), and then for the last case, I need to listen to the window scroll event. Unfortunately, I can't simply attach my callback to that event, as some browsers will fire it a lot and executing code after each event will slow down the page scrolling. Instead, I use Ben Alman's throttle library to only call my function every 500 milliseconds during the scroll event. Here's what that looks like:

$(window).on('scroll', $.throttle(500, loadVisibleImages));

The Result

After implementing this viewport-based image loading, my home stream went from loading 120 images to loading just 12 images — a much better experience for the browser and users. There are various improvements that could be made to this technique, like setting appropriate filler images for the different image types (a blank user pic for thumbnails, a meal icon for meals, etc) and maybe even using data URIs for those... but this works well enough for now. ☺

No comments: