Thursday, January 30, 2014

Improving front page performance: removing images, 5 ways

We regularly hold Fix-Its at Khan Academy, where we take a day to work on the little things that pile up, the bugs and tweaks and optimizations we don't find the time to do otherwise. On Tuesday, we had a Fix-It dedicated entirely to performance, and we made a Trello board of performance improvement ideas, many of them around speeding up our server-side calls.

I decided to tackle the frontend, starting with the very first experience a user has on Khan Academy: the logged out landing page. My colleague told me there were some images that could be delay loaded on it, a technique that I'm familiar with, so I logged out, opened up the Chrome Dev Tools Network panel, and checked out the HTTP requests (particularly the pre-DOMContentLoaded ones). Sure enough, lots of image requests!

Here's a screenshot of the page, with the various images circled in red:



Some of those images are "above the fold", meaning they're visible without scrolling, while others are "below the fold", requiring varying degrees of scrolling to see. This is an important distinction to make, because most users do not scroll on our landing page - most will go straight to signup, which is what we want to happen, and have optimized for in numerous A/B tests. Depending on their position on the page, I used different techniques to deal with each image. Let's go through them, shall we?



TinyPNG

For many of the techniques below, I first put the images through TinyPNG to optimize their size, and often achieved 50%-75% compression, without discernible loss in image quality. Plus, I made a Panda happy!


Top Logo: CSS Data URI

HTTP Requests saved: 1

The top logo is displayed on every Khan Academy webpage, so it will need to get downloaded by ever user in some form. Since we're already sending down a CSS file on every page, and the logo is a fairly small file (2KB, after compression), my colleague suggested just inlining it as data URI in the CSS file.

Thanks to the magic of all of our conversations happening in HipChat, I then found out that my colleague Craig Silverstein actually made it so that our CSS build process will automatically Data URI an image that's used as a background-image, as long as it's sufficiently small size and only included in one CSS package or if it has a /* data-uri */ comment next to it. If I hadn't had that option, I would have used one of the online converters - but I'm happy I get to keep our CSS files clean and readable!


Login icons: Sprites FontAwesome

HTTP Requests saved: 2

CSS sprites are a now classic technique for saving HTTP requests for the commonly used little images on your site, condensing them all into one image and using background-image/background-position magic. That seemed like the obvious approach at first for the remaining above-the-fold images on the page, the login icons and the purple leaves background. I optimized the images, uploaded them to this online CSS Sprite generator, and set their new CSS rules, happily turning 3 HTTP requests into 1.

However, my colleague then informed me that we actually had FontAwesome icons (in our custom build!) that looked nearly the same as the Google and Facebook login icons, and he'd been meaning to change those over. We already send down FontAwesome with every page, because we use their icons so heavily, so it made sense to give up images entirely and just use the FontAwesome classes.


Featured Images: Delay Load

HTTP Requests saved: 4

We display three images related to featured content just below the fold, which we don't want to sprite because we change them up a fair bit, but we also don't want to load for all the non-scrolling users out there. So I implemented this delay-loading technique for the images, which basically entails adding a scroll handler, checking if the desired DOM elements are within view or within some threshold of being in view, and swapping out their background-image or src if so, based on data attribute values.

Here's the generalized delay loading code that I wrote as a simple jQuery plugin:

Here's example HTML for one of the images:

<div data-delayed-bgimage="https://www.kastatic.org/images/featured-actions/pollock.jpg">
</div>

And here's the JS that calls the delay loading plugin for those images:

var maybeLoadMedia = function(nearThreshold) {
    $("#homepage [data-delayed-bgimage]").each(function() {
        $(this).delayLoad(nearThreshold);
    });
};

// On scroll, use very large threshold. Most people don't scroll homepage,
// but if they do, we want them to see everything.
$(window).on("scroll.load-media", _.throttle(function() {
    maybeLoadMedia(600);
}, 100));

// Start with no threshold, items must be visible
maybeLoadMedia(0);

Youtube Video: Delay Load

HTTP Requests saved: 1+

Way below the fold, we embed an iframe of Sal's TED talk. It's a great talk, but once again, most users won't make it this far. We use the same technique as for the images, with one difference: for img tags, we start them off with an empty src attribute. However, that's not a valid value for src for the iframe, so we start the iframe off with src="about:blank". Ben Vinegar, an iframe wrangling expert from Disqus, suggests it's even better to completely create the iframe from scratch, but since the src swapping worked, I went the simpler route this time.

Here's what the HTML looked like:

<iframe width="560" height="315" frameborder="0" allowfullscreen
  data-delayed-src="//www.youtube.com/embed/gM95HHI4gLk" src="about:blank">
</iframe>

And the JS was pretty much the same:

var maybeLoadMedia = function(nearThreshold) {
    $("#homepage .youtube-video iframe").each(function() {
        $(this).delayLoad(nearThreshold);
    });
};

$(window).on("scroll.load-media", _.throttle(function() {
    maybeLoadMedia(600);
}, 100));
maybeLoadMedia(0);

Social Icons: Delay Load

HTTP Requests saved: 0 (all were async tags)

We also display a host of social icons next to the video, and we have to load in the relevant JS APIs for each of those, often via dynamically appending a script tag. We set the async attribute on all of those, so they were not affecting our DOMContentLoaded time, but hey, might as well delay load them! No sense in making users do HTTP requests for things they won't need.

var maybeLoadSocialMedia = function(nearThreshold) {
    if ($("#homepage #social-actions").inView(nearThreshold)) {
        _.defer(function() {
            Homepage.initSocialButtons();
        });
        $(window).off("scroll.load-social-media");
    }
};

$(window).on("scroll.load-social-media", _.throttle(function() {
    maybeLoadSocialMedia(1000);
}, 100));

Subscribe icon: FontAwesome

HTTP Requests saved: 1

I started off by delay loading the RSS subscribe icon, but it felt like overkill and I realized there was a much easier approach: just use the FontAwesome subscribe icon, and color it orange. Ta-da, nearly the same!

It was easy to do with this HTML:

<span class="icon-rss-sign"></span>

...and this CSS

.icon-rss-sign {
  color: orange;
  text-decoration: none;
  margin-right: 5px;
  font-size: 18px;
}

Invisible image: Delete!

HTTP Requests saved: 1

After I'd optimized all the images I could see, there was still an HTTP request for an image that I couldn't ever see: our loading gif. I spent a while trying to track down what code was loading it, to see if it was validly being used at some point during the page rendering process. I finally discovered that it was in a CSS rule for an element that was visible on the page - but its visibility was set to hidden. Apparently, browsers (or at least Chrome) will download background-image's for hidden elements. Luckily, I then discovered that it was "dead" CSS - the rule that would toggle its visibility was never getting triggered. We'd stopped using the loading image without removing the associated CSS rules, and that was costing an extra HTTP request. I happily and promptly deleted it, whee.



The Exciting Results!

If you've been keeping score, my changes removed at least 10 HTTP requests, if not more (like additional ones triggered from the Youtube iframe). I recorded load times across 5 page loads in Chrome Dev Tools both before and after the changes, and here are the results:

# of Requests DOMContentLoaded (average) load (average Time between them (average)
BEFORE 65 3.02 5.66 2.64
AFTER 28 1.86 2.00 0.14
% REDUCED 57% 39% 65% 95%

The full spreadsheet is here, and I've also downloaded the HARs from all the loads.

There is still much we can improve - there's a lot of latency and variability in the initial download of the HTML page itself, plus we're passing down several CSS, JS, and font files. But still, it's impressive what we could do in a single day, with just the images. Thanks to those techniques, the page makes it to the DOMContentLoaded event in nearly half the time, and to the load event in less than half the time.

Got any other techniques to share, or tips for how you improve your site's performance? Tell us in the comments!

No comments: