Layered Image Loading

An attempt to use big, beautiful backgrounds responsibly.

I recently finished a responsive site for a new client. The design featured rather large (full-width) header images with some overlaid type on each page. Performance on mobile was a big issue for this project, so naturally, downloading huge images to all devices was not an option - I needed a better solution.

I wanted a smaller-sized background to be the default and then progressively enhance from there. That way, if something wasn’t supported, users on slow networks would still receive the small asset.

The site uses the newly adopted responsive images syntax (and picturefill.js) in other sections for img tags, but that doesn’t cover background images. Using standard media queries and hard-code the sources in CSS wasn’t really possible either, since the images were dynamically set in the backend - plus since the site was built mobile-first, some browsers might have downloaded unnecessary resources anyway.

Then I came across this article on CSS-Tricks, describing a method originally intended to asnychronously deliver retina images to high-densitiy displays.

It takes advantage of CSS3 multiple backgrounds to load a low-res image first and then re-sharpen it, mimicking the beaviour of the `LOWSRC attribute in early browsers.

This gave me an idea, altough I had to modify the technique to do a little more. Basically I wanted to achieve this:

  • load the small background image per default (mobile first)
  • If we're above a certain breakpoint, wait until window.load …
  • …then fetch the hi-res image and smoothly replace the low-res version
  • once the hi-res image is cached on a device, override default behavior and load the large version from now on.

The background image sizes are auto-generated by the server. I used two versions: the small one at 500x200 px, the larger one at 1200x528. For the layered loading effect to work, the images need to have the exact same aspect ratio - otherwise, there would be a visible “jump” once the second one loads.

The markup here is pretty simple, just a div with a background image, set to fill the entire header using background-size:cover. Since the image needs to be set in the backend, the source is printed as inline CSS. Additional information is passed via data attributes: data-bg-large is the path to the hi-res image, data-bg-id is the internal ID of the asset in the database:

<div class="page-header">
  <div class="bgimg" style="background-image:url(background-small.jpg)" 
         data-bg-large="background-large.jpg" data-bg-id="123"></div>
</div>

For small devices, there’s nothing more to do. The 500px background will look reasonably sharp on most phones and is quick(er) to load. Above 500px display width, it will be scaled to fill the container - which is OK up to a certain point where it just gets too blurry and we need to fetch the larger image.

The interesting part is the script that handles the source swapping. It fires after the page is done in order to not affect the perceived loading speed and looks for data attributes on the image container. If they’re present and the viewport is wide enough, it preloads the hi-res image and then extends the `background-image style with the second (large) source. The browser will render the new image on top of the low-res background, resulting in a “sharpening” kind of effect on bigger screens.

Once we know a device needs the large image and has already cached it, we can then bypass the whole routine and just serve the asset straight away. So the image’s ID is stored in a cookie along with the others.

var headerImage = {
      init: function(){
        var $bgImg = $('.page-header .bgimg'),
            srcL = $bgImg.attr('data-bg-large'),
            id = $bgImg.attr('data-bg-id');

        // if there's a data-bg-large attr present and we're above the breakpoint
        if((typeof srcL !== typeof undefined &amp;&amp; srcL !== false) &amp;&amp; ww() &gt;= bp.medium){
          var srcS = $bgImg.css('background-image'),
              tmpImg = $('img');

          // preload large image, then swap sources
          tmpImg.onload = this.srcSwap($bgImg, srcS, srcL);
          tmpImg.src = srcL;

          // write the image ID to a cookie
          // once the large version has been cached
          this.setCookie(id);
        }
      },
      srcSwap: function($bgImg, srcS, srcL){
        // use the 'layered' technique if supported, otherwise just change src
        // remove data attr afterwards so it won't run again on resize
        var bg = 'url('+srcL+')';
        if(Modernizr.multiplebgs) bg += ', '+srcS;
        $bgImg.css({'background-image' : bg}).removeAttr('data-bg-large');
      },
      setCookie: function(id){
        var cachedIds,
            cookie = document.cookie.replace(/(?:(?:^|.*;s*)cachedBGs*=s*([^;]*).*$)|^.*$/, "$1"),
            exdate = new Date(),
            exdays = 365,
            seperator = '|';

        if(cookie.length){
          // cookie was previously set, check contents and update
          // if the current ID already exists, bail
          cachedIds = cookie.split(seperator);
          if(cachedIds.indexOf(id) &gt; -1){
            return false;
          }
          cachedIds.push(id);
          cachedIds = cachedIds.join(seperator);
        }
        else {
          // set initial cookie  
          cachedIds = id;
        }
        // write the cookie
        exdate.setTime(exdate.getTime()+(exdays*24*60*60*1000));
        document.cookie = "cachedBG="+cachedIds+"; expires="+exdate.toGMTString()+"; path=/";
      }
    }

// finally, run the code on window load and resize
$(window).bind('load resize', headerImage.init);

Now at the server side, I can check if the cookie is present on the next request and generate the markup accordingly. If that particular hi-res image has already been cached, just set it as the background, omit the data attributes, and the script will never run. If not, use the swapping magic.

<?php
function header_image(){
  if($image = get_header_image()){
    $id = $image['id'];
    $src_small = $image['sizes']['header-bg-small'];
    $src_large = $image['sizes']['header-bg-large'];
    $is_cached = false;

    // check cookie if this image has already been cached
    if(isset($_COOKIE['cachedBG'])){
      $cached_ids = explode('|', $_COOKIE['cachedBG']);
      $is_cached = in_array($id, $cached_ids);
    }

    $out = '<div class="bgimg" ';
    if($is_cached){
      $out .= 'style="background-image:url('.$src_large.')" ';
    }
    else {
      $out .= 'style="background-image:url('.$src_small.')" ';
      $out .= 'data-bg-large="'.esc_attr($src_large).'" ';
    }
    $out .= 'data-bg-id="'.$id.'" ></div>;';
    echo $out;
  }
}
?>

As with any technique, there are caveats to this one too. Most notably, it relies on Javascript, which can be disabled. It also fails in some scenarious where the browser cache is emptied, although that might delete the cookie too.

In most cases though, due to progressive enhancement, browsers fall back to the default behaviour and either serve the small image, or load the large one asynchronously.